Как избежать проблем согласованности кэша в Delphi с критическим разделом - PullRequest
11 голосов
/ 28 августа 2011

Я только что прочитал статью MSDN, "Проблемы синхронизации и многопроцессорности" , в которой рассматриваются проблемы согласованности кэша памяти на многопроцессорных компьютерах. Это действительно открыло мне глаза, потому что я бы не подумал, что в приведенном ими примере может быть условие гонки. В этой статье объясняется, что записи в память могут фактически не происходить (с точки зрения другого процессора) в порядке, указанном в моем коде. Это новая концепция для меня!

В этой статье представлены 2 решения:

  1. Использование ключевого слова «volatile» для переменных, которым требуется согласованность кэша для нескольких процессоров. Это ключевое слово C / C ++, и оно мне недоступно в Delphi.
  2. Использование InterlockExchange () и InterlockCompareExchange (). Это то, что я мог бы сделать в Delphi, если бы мне пришлось. Это просто кажется немного грязным.

В статье также упоминается, что «Следующие функции синхронизации используют соответствующие барьеры для обеспечения упорядочения памяти: • Функции, которые входят или выходят из критических секций».

Эту часть я не понимаю. Означает ли это, что любые записи в память, которые ограничены функциями, использующими критические разделы, защищены от проблем целостности кэша и упорядочения памяти? Я ничего не имею против функций Interlock * (), но было бы неплохо иметь другой инструмент в моем поясе инструментов!

Ответы [ 2 ]

8 голосов
/ 29 августа 2011

Эта статья MSDN - только первый шаг в разработке многопоточных приложений: короче говоря, это означает «защитить ваши общие переменные с помощью блокировок (или критических разделов), потому что вы не уверены, что данные, которые вы читаете / пишете, являются одинаково для всех тем ".

Кэш процессора на ядро ​​- лишь одна из возможных проблем, которая приведет к чтению неправильных значений. Еще одна проблема, которая может привести к состязанию, - это одновременная запись в ресурс двух потоков: невозможно определить, какое значение будет сохранено позже.

Поскольку код ожидает, что данные будут согласованными, некоторые многопоточные программы могут работать неправильно. При многопоточности вы не уверены, что код, который вы пишете с помощью отдельных инструкций, выполняется должным образом, когда он имеет дело с общими переменными.

InterlockedExchange/InterlockedIncrement функции - это низкоуровневые коды операций asm с префиксом LOCK (или блокированные по конструкции, такие как код операции XCHG EDX,[EAX]), которые действительно выльются в когерентность кэша для всех ядер ЦП и, следовательно, заставляют выполнение кода операции asm потокобезопасный.

Например, вот как реализован счетчик ссылок на строки, когда вы присваиваете строковое значение (см. _LStrAsg в System.pas - это от нашей оптимизированной версии RTL для Delphi 7/2002 - поскольку оригинальный код Delphi защищен авторским правом):

            MOV     ECX,[EDX-skew].StrRec.refCnt
            INC     ECX   { thread-unsafe increment ECX = reference count }
            JG      @@1   { ECX=-1 -> literal string -> jump not taken }
            .....
       @@1: LOCK INC [EDX-skew].StrRec.refCnt { ATOMIC increment of reference count }
            MOV     ECX,[EAX]   
            ...

Существует разница между первыми INC ECX и LOCK INC [EDX-skew].StrRec.refCnt - не только первые инкременты ECX и не переменная подсчета ссылок, но и первая не являются поточно-ориентированными, в то время как 2-й имеет префикс LOCK, поэтому будет быть потокобезопасным.

Кстати, этот префикс LOCK является одной из проблем многопоточного масштабирования в RTL - лучше с новыми процессорами, но все же не идеально.

Таким образом, использование критических разделов - это самый простой способ сделать код потокобезопасным:

var GlobalVariable: string;
    GlobalSection: TRTLCriticalSection;

procedure TThreadOne.Execute;
var LocalVariable: string;
begin
   ...
   EnterCriticalSection(GlobalSection);
   LocalVariable := GlobalVariable+'a'; { modify GlobalVariable }
   GlobalVariable := LocalVariable;
   LeaveCriticalSection(GlobalSection);
   ....
end;

procedure TThreadTwp.Execute;
var LocalVariable: string;
begin
   ...
   EnterCriticalSection(GlobalSection);
   LocalVariable := GlobalVariable; { thread-safe read GlobalVariable }
   LeaveCriticalSection(GlobalSection);
   ....
end;

Использование локальной переменной делает критическую секцию короче, поэтому ваше приложение будет лучше масштабироваться и использовать всю мощь ядер вашего процессора. Между EnterCriticalSection и LeaveCriticalSection будет работать только один поток: другие потоки будут ожидать вызова EnterCriticalSection ... Чем короче критическая секция, тем быстрее ваше приложение. Некоторые неправильно спроектированные многопоточные приложения могут работать медленнее, чем однопоточные!

И не забывайте, что если ваш код внутри критического раздела может вызвать исключение, вы всегда должны писать явный блок try ... finally LeaveCriticalSection() end;, чтобы защитить снятие блокировки и предотвратить любую мертвую блокировку вашего приложения.

Delphi идеально поточно-ориентирован, если вы защищаете свои общие данные с помощью блокировки, то есть критического раздела. Помните, что даже переменные с подсчетом ссылок (например, строки) должны быть защищены, даже если в их функциях RTL есть LOCK: этот LOCK предназначен для правильного подсчета ссылок и предотвращения утечек памяти, но он не будет поточно-ориентированным , Чтобы сделать это как можно быстрее, см. Этот вопрос .

Цель InterlockExchange и InterlockCompareExchange - изменить значение переменной общего указателя. Вы можете видеть его как «облегченную» версию критической секции для доступа к значению указателя.

Во всех случаях писать рабочий многопоточный код непросто - это даже трудный , , как только что написал эксперт Delphi в своем блоге .

Вы должны либо написать простые потоки без каких-либо общих данных (сделать личную копию данных до запуска потока, либо использовать совместно используемые данные только для чтения - которые по своей сути поточнобезопасны), либо вызвать некоторые хорошо спроектированные и проверенные библиотеки - такие как http://otl.17slon.com - которые сэкономят вам много времени на отладку.

7 голосов
/ 29 августа 2011

Прежде всего, согласно языковым стандартам, volatile не делает то, о чем говорится в статье. Семантика приобретения и выпуска volatile специфична для MSVC. Это может быть проблемой, если вы компилируете с другими компиляторами или на других платформах. В C ++ 11 введены поддерживаемые языком атомарные переменные, которые, надеюсь, со временем, наконец, положат конец (неправильному) использованию volatile в качестве потоковой конструкции.

Критические секции и мьютексы действительно реализованы таким образом, что чтение и запись защищенных переменных будут правильно видны из всех потоков.

Я думаю, что лучший способ думать о критических секциях и мьютексах (блокировках) - это устройства, обеспечивающие сериализацию. То есть блоки кода, защищенные такими блокировками, выполняются последовательно, один за другим, без перекрытия. Сериализация распространяется и на доступ к памяти. Не может быть проблем из-за согласованности кэша или переупорядочения чтения / записи.

Блокированные функции реализуются с помощью аппаратных блокировок на шине памяти. Эти функции используются безблокировочными алгоритмами. Это означает, что они используют не тяжелые замки типа критических секций, а скорее легкие аппаратные замки.

Алгоритмы без блокировок могут быть более эффективными, чем алгоритмы, основанные на блокировках, но алгоритмы без блокировок могут быть намного труднее написать правильно. Предпочитайте критические секции свободным от блокировки, если не заметны последствия для производительности.

Еще одна статья, которую стоит прочесть, - Декларация "Двойная проверка блокировки взломана" .

...