Синхронизация потоков. Как именно блокировка делает доступ к памяти "правильной"?
Прежде всего, я знаю, что lock{}
- синтетический сахар для класса Monitor
. (oh, синтаксический)
Я играл с несколькими задачами многопоточности и обнаружил, что не может полностью понять, как блокировка какого-либо произвольного WORD памяти защищает целую другую память от кэширования - это регистры/кеш процессора и т.д. Легче использовать примеры кода для объяснения того, что я говорю о:
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. Ушел читать книги о многопоточности.
ПОДОБНЫЙ ВОПРОС (S)
Как оператор блокировки обеспечивает синхронизацию внутри процессора?
Синхронизация потоков. Почему именно этой блокировки недостаточно для синхронизации потоков.
Ответы
Ответ 1
почему именно lock(ms_Lock) { ms_Counter += 1; }
делает программу полностью правильной, но lock(ms_Lock) {}; ms_Counter += 1;
только почти правильной?
Хороший вопрос! Ключом к пониманию этого является то, что блокировка выполняет две вещи:
- Он вызывает любой поток, который оспаривает блокировку для приостановки до тех пор, пока блокировка не будет выполнена
- Это вызывает барьер памяти, также иногда называемый "полным забором"
Я не совсем понимаю, как блокировка какого-либо произвольного объекта запрещает кэширование другой памяти в кэшах регистров/ЦП и т.д.
Как вы заметили, кеширование памяти в регистрах или кеш процессора может привести к возникновению нечетных вещей в многопоточном коде. (См. мою статью о неустойчивости для нежного объяснения связанной темы..) Вкратце: если один поток создает копию страницы памяти в кэш ЦП до того, как другой поток изменит эту память, а затем первый поток выполнит чтение из кеша, тогда эффективно первый поток переместил чтение назад во времени. Точно так же запись в память может быть перемещена вперед во времени.
Загрязнение памяти похоже на забор во времени, что говорит о том, что процессор "делает то, что вам нужно сделать, чтобы обеспечить чтение и запись, которые перемещаются во времени, не могут пройти мимо забора".
Интересный эксперимент состоял бы в том, чтобы вместо пустой блокировки поместить вызов Thread.MemoryBarrier() туда и посмотреть, что произойдет. Получаете ли вы те же результаты или разные? Если вы получите тот же результат, то это барьер памяти, который помогает. Если вы этого не сделаете, то факт, что потоки почти синхронизированы правильно, - это то, что замедляет их настолько, чтобы предотвратить большинство рас.
Я предполагаю, что это последнее: пустые блокировки замедляют потоки настолько, что они не проводят большую часть своего времени в коде, который имеет состояние гонки. Для сильных процессоров модели памяти обычно не требуются барьеры памяти. (Являетесь ли вы на машине x86 или Itanium или какие? X86-машины имеют очень сильную модель памяти, у Itaniums есть слабая модель, которая нуждается в барьерах памяти.)
Ответ 2
Вы не говорите, сколько потоков вы использовали, но я угадываю два - если вы бежите с четырьмя потоками, я ожидаю, что разблокированная версия завершится с результатом, который достаточно близко к 1/4 одного "исправленный" результат версии.
Если вы не используете lock
, ваша машина с четырьмя процессорами выделяет поток для каждого процессора (это утверждение позволяет ограничить присутствие других приложений, которые также будут назначены по очереди, для простоты), и они работают на полной скорости, без помех друг другу. Каждый поток получает значение из памяти, увеличивает его и сохраняет его обратно в память. Результат перезаписывает то, что означает, что означает, что, поскольку у вас есть 2 (или 3 или 4) потока, работающие на полной скорости в одно и то же время, некоторые из приращений, созданных потоками на других ваших ядрах, эффективно удаляются. Таким образом, ваш конечный результат ниже, чем тот, который вы получили из одного потока.
Когда вы добавляете оператор lock
, это говорит CLR (это похоже на С#?), чтобы гарантировать, что только один поток на любом доступном ядре может выполнить этот код. Это критическое изменение ситуации выше, поскольку теперь несколько потоков мешают друг другу, хотя, как вы понимаете, этот код не является потокобезопасным (достаточно близко, чтобы это было опасно). Этот неправильный результат сериализации (как побочный эффект) в последующем приращении выполняется одновременно реже - поскольку подразумеваемая разблокировка требует дорогого, в терминах этого кода и вашего многоядерного ЦП, по крайней мере, пробуждения любых потоков, которые были ожидая блокировки. Из-за этих накладных расходов эта многопоточная версия будет работать медленнее, чем однопоточная. Нитки не всегда делают код быстрее.
В то время как любые ожидающие потоки пробуждаются из состояния ожидания, поток, освобождающий блокировку, может продолжать работать в своем временном фрагменте и часто будет получать, увеличивать и сохранять переменную до пробуждающих потоков получите шанс взять копию переменной из памяти для собственного приращения op. Таким образом, вы заканчиваете конечное значение, близкое к однопоточной версии, или то, что вы получили бы, если бы вы lock
-или инкремент внутри цикла.
Ознакомьтесь с классом Interlocked для аппаратного уровня для обработки переменных определенного типа атомарно.
Ответ 3
Если у вас нет блокировки общей переменной 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, поэтому вы приближаетесь к желаемому результату. Если вы делаете что-то более сложное (принимая больше времени на обработку), вы обнаружите, что он не приблизится к вашему желаемому результату.
Ответ 4
Мы обсуждали это с deafsheep, и наша текущая идея может быть представлена в виде следующей схемы
![enter image description here]()
Время работает слева направо, а 2 потока представлены двумя строками.
где
- черный ящик представляет собой процесс приобретения, хранения и выпуска
блокировка
- plus представляет операцию добавления (схема представляет масштаб на моем
ПК, блокировка занимает приблизительно 20 раз дольше, чем добавление)
- white box представляет собой период, который состоит из попытки получить блокировку,
и далее ожидая, что он станет доступным.
Порядок черных ящиков всегда такой, они не могут пересекаться, и они должны всегда следовать друг за другом очень близко. Следовательно, становится очень логично, что плюсы никогда не пересекаются, и мы должны подойти точно к ожидаемой сумме.
Источник существующей ошибки исследуется в question:
Ответ 5
Вот ответ.
Я не читал все остальные ответы, потому что они были слишком длинными, и я видел вещи, которые были неправильными, и ответ не должен быть таким долгим. Может быть, ответ Седата был самым близким. Это не имеет никакого отношения к тому, что оператор блокировки "замедляет" скорость программы.
Он связан с синхронизацией кэша ms_sum между двумя потоками. Каждый поток имеет свою собственную кешированную копию ms_sum.
В вашем первом примере, поскольку вы не используете "блокировку", вы оставляете его в ОС относительно того, когда делать синхронизацию (когда копировать обновленное значение кеша обратно на главную памяти или когда читать его из основной памяти в кеш). Итак, каждый поток в основном обновляет собственную копию ms_sum. Теперь синхронизация происходит время от времени, но не на каждом переключателе контекста потока, что приводит к тому, что результат будет чуть больше 50 000 000. Если это произошло при каждом переключении контекста потока, вы получите 10 000 000.
В втором примере, ms_sum синхронизируется на каждой итерации. Это позволяет ms_sum # 1 и ms_sum # 2 хорошо синхронизироваться. Итак, вы получите почти 10 000 000. Но это не будет до 10 000 000, потому что каждый раз, когда контекст потока переключается, ms_sum может быть отключен на 1, потому что у вас есть + = происходит за пределами блокировки.
Теперь, в общем, точно, какие части кешей различных потоков синхронизируются при вызове блокировки, мне немного неизвестно. Но из-за вашего результата почти 10 000 000 в вашем втором примере я вижу, что ваш вызов блокировки вызывает синхронизацию ms_sum.