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

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

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

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

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

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

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. Ушел читать книги о многопоточности.

ПОХОЖИЕ ВОПРОС (ы)

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

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

Ответы [ 5 ]

15 голосов
/ 20 августа 2011

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

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

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

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

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

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

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

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

1 голос
/ 30 августа 2016

Вот ответ.

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

Это связано с синхронизацией кеша 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. Но до 10000000 это будет не так, потому что каждый раз, когда контекст потока переключается, ms_sum может быть отключен на 1, потому что у вас + + происходит вне блокировки.

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

1 голос
/ 01 сентября 2011

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

enter image description here

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

, где

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

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

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

1 голос
/ 20 августа 2011

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

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, так что вы приближаетесь к желаемому результату. Если бы вы делали что-то более сложное (занимало больше времени на обработку), вы бы обнаружили, что оно не будет настолько близко к вашему желаемому результату.

1 голос
/ 20 августа 2011

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

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

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

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

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

...