Синхронизация потоков. Как именно блокировка делает доступ к памяти «правильным»?

Прежде всего, я знаю, что lock{} синтетический сахар для Monitor класса. (о, синтаксический сахар)

Я играл с простыми проблемами многопоточности и обнаружил, что не могу полностью понять, как блокировка некоторого произвольного СЛОВА памяти защищает всю другую память от кеширования, это регистры / кеш процессора и т. Д. Легче использовать образцы кода, чтобы объяснить, о чем я говорю:

for (int i = 0; i < 100 * 1000 * 1000; ++i) {
    ms_Sum += 1;
}

В конце ms_Sum будет содержать 100000000, что, конечно, ожидается.

Теперь мы собираемся выполнить один и тот же цикл, но на 2 разных потоках и с уменьшенным вдвое верхним пределом.

for (int i = 0; i < 50 * 1000 * 1000; ++i) {
    ms_Sum += 1;
}

Из-за отсутствия синхронизации мы получаем неверный результат - на моей 4-ядерной машине это случайное число почти 52 388 219, что немного больше половины от 100 000 000. Если мы заключим ms_Sum += 1; в lock {}, мы, естественно, получим абсолютно правильный результат 100 000 000. Но что меня интересует (честно говоря, я ожидал аналогичного поведения), добавление строки lock до или после ms_Sum += 1; делает ответ почти правильным:

for (int i = 0; i < 50 * 1000 * 1000; ++i) {
    lock (ms_Lock) {}; // Note curly brackets

    ms_Sum += 1;
}

В этом случае я обычно получаю ms_Sum = 99 999 920, что очень близко.

Вопрос: почему именно lock(ms_Lock) { ms_Counter += 1; } делает программу полностью правильной, а lock(ms_Lock) {}; ms_Counter += 1; только почти правильной; как блокировка произвольной ms_Lock переменной делает всю память стабильной?

Большое спасибо!

P.S. Читал книги о многопоточности.

ПОДОБНЫЕ ВОПРОСЫ

Как оператор блокировки обеспечивает синхронизацию внутри процессора?

Синхронизация потоков. Почему именно этой блокировки недостаточно для синхронизации потоков


person Roman    schedule 20.08.2011    source источник
comment
Вы имеете в виду синтаксический сахар. :)   -  person Hugh    schedule 20.08.2011
comment
@ Хью: в этом есть что-то синтетическое.   -  person Henk Holterman    schedule 20.08.2011
comment
Остаточную ошибку никто не объяснил. Это вызвано планировщиком потоков Windows. Запустите версию без блокировки с помощью start.exe / affinity 1, чтобы получить сопоставимые результаты.   -  person Hans Passant    schedule 20.08.2011


Ответы (5)


почему именно lock(ms_Lock) { ms_Counter += 1; } делает программу полностью правильной, а lock(ms_Lock) {}; ms_Counter += 1; только почти правильной?

Хороший вопрос! Ключ к пониманию этого заключается в том, что блокировка выполняет две функции:

  • Это приводит к тому, что любой поток, который оспаривает блокировку, приостанавливается, пока блокировка не будет снята.
  • Это вызывает барьер памяти, также иногда называемый "полным забором"

Я не совсем понимаю, как блокировка некоторого произвольного объекта предотвращает кэширование другой памяти в регистрах / кеш-памяти процессора и т. Д.

Как вы заметили, кэширование памяти в регистрах или кеш-памяти ЦП может вызывать странные вещи в многопоточном коде. (См. Мою статью о волатильности для мягкого объяснения связанной темы..) Вкратце: если один поток делает копию страницы памяти в кэше ЦП до изменения другого потока эта память, а затем первый поток выполняет чтение из кеша, а затем, по сути, первый поток переместил чтение назад во времени. Точно так же записи в память могут казаться перемещенными вперед во времени.

Барьер памяти подобен забору во времени, который говорит процессору: «Делайте то, что вам нужно сделать, чтобы чтение и запись, которые перемещаются во времени, не могли пройти за забор».

Интересный эксперимент - вместо пустой блокировки поместить туда вызов Thread.MemoryBarrier () и посмотреть, что произойдет. Вы получаете такие же результаты или разные? Если вы получите тот же результат, значит, помогает барьер памяти. Если вы этого не сделаете, то тот факт, что потоки почти синхронизируются правильно, - это то, что замедляет их в достаточной степени, чтобы предотвратить большинство гонок.

Я предполагаю, что это последнее: пустые блокировки настолько замедляют потоки, что они не проводят большую часть своего времени в коде, который имеет состояние гонки. Барьеры памяти обычно не требуются для процессоров с сильной моделью памяти. (Вы используете машину x86, Itanium или что-то еще? Машины x86 имеют очень сильную модель памяти, Itanium имеют слабую модель, которая требует барьеров памяти.)

person Eric Lippert    schedule 20.08.2011
comment
Эрик, тестирую этот код на x86 и x64, работает точно так же. Замена пустой блокировки некоторым кодом, который только замедляет выполнение потока, действительно помогает приблизиться к 100 000 000 (90 633 072), но не так эффективно, как пустой lock. Звонок Thread.MemoryBarrier() не очень помогает (68 511 152). - person Roman; 20.08.2011
comment
@Roman - пустая блокировка переводит поток в состояние ожидания при конфликте блокировок - если только добавленный вами код задержки не делает этого, расхождение, которое вы видите, полностью понятно. Если вы находитесь в состоянии ожидания, выходить из него дорого. Намного дороже, чем простое выполнение нескольких задерживающих инструкций / строк кода. - person Steve Townsend; 20.08.2011
comment
@Steve - "Much more costly than simply executing a few delaying instructions/lines of code" - даже когда я помещаю несколько сотен инструкций по задержке, так что общее время цикла 100mln становится намного больше, чем время цикла с lock, результат недостаточно близок к 100mln. Таким образом, lock нельзя просто заменить соответствующим кодом ожидания. - person Roman; 20.08.2011
comment
@Eric - что касается Thread.MemoryBarrier(), это на 100% помогает, когда этот двухпоточный код выполняется на одноядерной машине или с ProcessorAffinity для обоих потоков на одно ядро ​​... и это имеет смысл. - person Roman; 20.08.2011
comment
@Roman - я не предполагал, что код ожидания является семантической заменой блокировки при любых обстоятельствах, если только код ожидания не делает что-то, чтобы вызвать явное переключение потока со стороны CLR. - person Steve Townsend; 21.08.2011
comment
@ Роман: Я подозреваю, что если бы у вас была такая блокировка, проблемы были бы полностью решены по причинам, упомянутым Эриком (хотя, конечно, это глупое решение, оно просто показывает, что блокировки делают доступ более синхронизированным): lock(ms_Lock) { Thread.Sleep(100); } ms_Counter++; - person configurator; 31.08.2011

Вы не говорите, сколько потоков вы использовали, но я предполагаю, что два - если вы работали с четырьмя потоками, я бы ожидал, что разблокированная версия завершится с результатом, который достаточно близок к 1/4 от однопоточной версии «правильный» результат.

Когда вы не используете lock, ваша четырехпроцессорная машина выделяет поток каждому ЦП (это утверждение не учитывает присутствие других приложений, которые также будут запланированы по очереди, для простоты), и они будут работать на полной скорости, без вмешательства в друг с другом. Каждый поток получает значение из памяти, увеличивает его и сохраняет обратно в память. Результат перезаписывает то, что там есть, а это означает, что, поскольку у вас есть 2 (или 3, или 4) потока, работающие на полной скорости одновременно, некоторые из приращений, сделанных потоками на других ваших ядрах, эффективно выбрасываются. Таким образом, ваш окончательный результат ниже, чем у одного потока.

Когда вы добавляете оператор lock, он сообщает CLR (это похоже на C #?), Чтобы гарантировать, что только один поток на любом доступном ядре может выполнить этот код. Это критическое изменение по сравнению с ситуацией выше, поскольку несколько потоков теперь мешают друг другу, хотя, как вы понимаете, этот код не является потокобезопасным (достаточно близко к этому, чтобы быть опасным). Эта неправильная сериализация приводит (как побочный эффект) к тому, что последующее приращение выполняется одновременно реже - поскольку подразумеваемая разблокировка требует дорогостоящего, с точки зрения этого кода и вашего многоядерного процессора, по крайней мере, пробуждения любых потоков, которые были ждем блокировки. Эта многопоточная версия также будет работать медленнее, чем однопоточная версия из-за этих накладных расходов. Потоки не всегда ускоряют код.

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

Ознакомьтесь с классом Interlocked на уровне оборудования для атомарной обработки переменных определенного типа.

person Steve Townsend    schedule 20.08.2011
comment
+1. Interlocked.Increment (ref ms_Sum) определенно то, что вам здесь нужно. - person Hugh; 20.08.2011
comment
Я знаю о классе Interlocked, но его нельзя использовать в одной из основных частей моего вопроса lock(ms_Lock) {}; ms_Counter += 1;, поэтому я не упомянул об этом. - person Roman; 20.08.2011

Если у вас нет блокировки вокруг общей переменной ms_Sum, тогда оба потока могут получить доступ к переменной ms_Sum и увеличивать значение без ограничений. 2 потока, выполняющиеся параллельно на двухъядерном компьютере, будут одновременно работать с переменной.

Memory: ms_Sum = 5
Thread1: ms_Sum += 1: ms_Sum = 5+1 = 6
Thread2: ms_Sum += 1: ms_Sum = 5+1 = 6 (running in parallel).

Вот приблизительная разбивка событий, которые я могу объяснить:

1: ms_sum = 5.
2: (Thread 1) ms_Sum += 1;
3: (Thread 2) ms_Sum += 1;
4: (Thread 1) "read value of ms_Sum" -> 5
5: (Thread 2) "read value of ms_Sum" -> 5
6: (Thread 1) ms_Sum = 5+1 = 6
6: (Thread 2) ms_Sum = 5+1 = 6

Имеет смысл, что без синхронизации / блокировки вы получите результат примерно вдвое меньше ожидаемого, поскольку 2 потока могут делать что-то «почти» вдвое быстрее.

При правильной синхронизации, то есть lock(ms_Lock) { ms_Counter += 1; }, порядок меняется примерно так:

 1: ms_sum = 5.
 2: (Thread 1) OBTAIN LOCK. ms_Sum += 1;
 3: (Thread 2) WAIT FOR LOCK.
 4: (Thread 1) "read value of ms_Sum" -> 5
 5: (Thread 1) ms_Sum = 5+1 = 6
 6. (Thread 1) RELEASE LOCK.
 7. (Thread 2) OBTAIN LOCK.  ms_Sum += 1;
 8: (Thread 2) "read value of ms_Sum" -> 6
 9: (Thread 2) ms_Sum = 6+1 = 7
10. (Thread 2) RELEASE LOCK.

Что касается того, почему lock(ms_Lock) {}; ms_Counter += 1; "почти" правильно, я думаю, вам просто повезло. Блокировка заставляет каждый поток замедляться и «ждать своей очереди», чтобы получить и снять блокировку. Тот факт, что арифметическая операция ms_Sum += 1; настолько тривиальна (она выполняется очень быстро), вероятно, поэтому результат "почти" нормальный. К тому времени, когда поток 2 выполнил накладные расходы на получение и снятие блокировки, простая арифметика, вероятно, уже выполнена потоком 1, так что вы приблизитесь к желаемому результату. Если бы вы выполняли что-то более сложное (требующее больше времени на обработку), вы бы обнаружили, что это не будет так близко к желаемому результату.

person JJ.    schedule 20.08.2011
comment
Ваша первая часть верна, но для второго объяснения я думаю, что это более вероятно, потому что lock является полным забором и создает барьер памяти, поэтому он приводит к изменению чтения следующей инструкции .. - person Jalal Said; 20.08.2011
comment
Может быть, если честно, я только догадываюсь о последней части. Очевидно, OP лучше всего рекомендуется использовать надлежащую блокировку для общей переменной. - person JJ.; 20.08.2011
comment
Спасибо, JJ. Если я правильно понимаю, когда какой-то поток видит переменную ms_Lock, заблокированную каким-то другим потоком, он аннулирует все уже кэшированные данные (в L0 и регистрах). С другой стороны, поток, покидающий lock, сбрасывает все кэшированные данные в ОЗУ. Это правильно? Я знаю, что подхожу слишком близко к деталям реализации ЦП / ОЗУ, но интересно, как ЦП знает, какую часть кэшированной памяти следует аннулировать. Если ЦП аннулирует весь кеш, это имеет смысл для меня, но я считаю, что ЦП делает более умную работу по аннулированию только части кеша. - person Roman; 20.08.2011
comment
Возможно, вы приближаетесь к деталям реализации, но понимание модели памяти .NET имеет решающее значение для написания правильных параллельных программ. Возможно, вам будет интересна эта статья: msdn.microsoft.com/en-us /magazine/cc163715.aspx - person Just another metaprogrammer; 20.08.2011

Мы обсуждали это с deafsheep, и нашу текущую идею можно представить в виде следующей схемы

введите описание изображения здесь

Время идет слева направо, и 2 потока представлены двумя строками.

где

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

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

Источник существующей ошибки исследуется в этом вопросе :

person Valentin Kuzub    schedule 31.08.2011

Вот и ответ.

Я не прочитал все остальные ответы полностью, потому что они были слишком длинными, и я видел вещи, которые были неправильными, и ответ не должен быть таким длинным. Возможно, ответ Седата был самым близким. На самом деле это не имеет ничего общего с оператором блокировки, который «замедляет» скорость программы.

Это связано с синхронизацией кеша ms_sum между двумя потоками. Каждый поток имеет свою кэшированную копию ms_sum.

В вашем 1-м примере, поскольку вы не используете «блокировку», вы оставляете на усмотрение ОС, когда выполнять синхронизацию (когда копировать обновленное значение кеша обратно в основную память или когда чтобы прочитать его из основной памяти в кеш). Итак, каждый поток в основном обновляет свою копию ms_sum. Теперь синхронизация действительно происходит время от времени, но не при каждом переключении контекста потока, что приводит к результату чуть больше 50 000 000. Если бы это происходило при каждом переключении контекста потока, вы бы получили 10 000 000.

В 2-м примере ms_sum синхронизируется на каждой итерации. Это позволяет довольно хорошо синхронизировать ms_sum # 1 и ms_sum # 2. Итак, вы получите почти 10 000 000. Но это не будет полностью до 10 000 000, потому что каждый раз, когда происходит переключение контекста потока, ms_sum может быть отключено на 1, потому что + = происходит за пределами блокировки.

В общем, мне немного неизвестно, какие именно части кешей различных потоков синхронизируются при вызове блокировки. Но из-за вашего результата почти 10 000 000 во втором примере я вижу, что ваш вызов блокировки вызывает синхронизацию ms_sum.

person N73k    schedule 30.08.2016