Что происходит, если два потока читают и записывают один и тот же кусок памяти
Насколько я понимаю, если два потока читают из одного куска памяти, и ни один поток не пишет в эту память, то операция безопасна. Однако я не уверен, что произойдет, если один поток читает, а другой пишет. Что случилось бы? Результат не определен? Или чтение будет устаревшим? Если устаревшее чтение не является проблемой, нормально ли иметь несинхронизированное чтение-запись в переменную? Или возможно, что данные будут повреждены, и ни чтение, ни запись не будут правильными, и в этом случае всегда следует синхронизировать данные?
Я хочу сказать, что я узнал, что это более поздний случай, когда гонка за доступом к памяти оставляет состояние неопределенным... но я не помню, где я мог это узнать, и мне трудно найти ответь в гугле. Моя интуиция заключается в том, что переменная используется в регистрах, и что истинный (как в аппаратном) параллелизм невозможен (или есть), так что худшее, что может произойти, это устаревшие данные, то есть следующее:
WriteThread: copy value from memory to register
WriteThread: update value in register
ReadThread: copy value of memory to register
WriteThread: write new value to memory
В этот момент поток чтения имеет устаревшие данные.
3 ответа
Результат не определен. Поврежденные данные вполне возможны. Для наглядного примера рассмотрим 64-битное значение, которым манипулирует 32-битный процессор. Давайте предположим, что значение является простым счетчиком, и мы увеличиваем его, когда младшие 32 бита содержат 0xffffffff. Приращение выдает 0x00000000. Когда мы обнаруживаем это, мы увеличиваем верхнее слово. Если, однако, какой-то другой поток считывает значение между моментом, когда нижнее слово увеличивается и верхнее слово увеличивается, они получают значение с неинкрементным верхним словом, но нижнее слово устанавливается в 0 - значение совершенно другое из того, что было бы до или после завершения приращения.
Обычно память считывается или записывается в атомарных единицах, определяемых архитектурой ЦП (элемент 32-бит и 64-бит, выровненный по 32-битной и 64-битной границам, является распространенным в наши дни).
В этом случае то, что происходит, зависит от объема записываемых данных.
Давайте рассмотрим случай 32-битных атомарных ячеек чтения / записи.
Если два потока записывают 32 бита в такую выровненную ячейку, то абсолютно точно определено, что происходит: одно из двух записанных значений сохраняется. К сожалению для вас (ну, программы), вы не знаете, какое значение. Благодаря чрезвычайно умному программированию вы можете использовать эту атомарность операций чтения и записи для построения алгоритмов синхронизации (например, алгоритма Деккера), но обычно вместо этого быстрее использовать архитектурно определенные блокировки.
Если два потока записывают больше, чем элементарная единица (например, они оба записывают 128-битное значение), то на самом деле части записанных значений размером в атомную единицу будут храниться абсолютно четко определенным образом, но вы не будете знать, какие именно части какого значения записываются в каком порядке. Таким образом, в памяти может оказаться значение из первого потока, второго потока или смеси битов в атомарных единицах размера из обоих потоков.
Аналогичные идеи применимы для чтения одного потока и записи одного потока в атомарных единицах и более.
По сути, вы не хотите выполнять несинхронизированное чтение и запись в области памяти, потому что вы не будете знать результат, даже если он может быть очень хорошо определен архитектурой.
Как я намекал в ответе Иры Бакстер, кэш-память процессора также играет роль в многоядерных системах. Рассмотрим следующий тестовый код:
ОПАСНОСТЬ БУДЕТ РОБИСОН!
Следующий код повышает приоритет в реальном времени для достижения несколько более согласованных результатов - хотя для этого требуются права администратора, будьте осторожны при запуске кода в двухъядерных или одноядерных системах, поскольку ваша машина будет зависать на время выполнения теста.
#include <windows.h>
#include <stdio.h>
const int RUNFOR = 5000;
volatile bool terminating = false;
volatile int value;
static DWORD WINAPI CountErrors(LPVOID parm)
{
int errors = 0;
while(!terminating)
{
value = (int) parm;
if(value != (int) parm)
errors++;
}
printf("\tThread %08X: %d errors\n", parm, errors);
return 0;
}
static void RunTest(int affinity1, int affinity2)
{
terminating = false;
DWORD dummy;
HANDLE t1 = CreateThread(0, 0, CountErrors, (void*)0x1000, CREATE_SUSPENDED, &dummy);
HANDLE t2 = CreateThread(0, 0, CountErrors, (void*)0x2000, CREATE_SUSPENDED, &dummy);
SetThreadAffinityMask(t1, affinity1);
SetThreadAffinityMask(t2, affinity2);
ResumeThread(t1);
ResumeThread(t2);
printf("Running test for %d milliseconds with affinity %d and %d\n", RUNFOR, affinity1, affinity2);
Sleep(RUNFOR);
terminating = true;
Sleep(100); // let threads have a chance of picking up the "terminating" flag.
}
int main()
{
SetPriorityClass(GetCurrentProcess(), REALTIME_PRIORITY_CLASS);
RunTest(1, 2); // core 1 & 2
RunTest(1, 4); // core 1 & 3
RunTest(4, 8); // core 3 & 4
RunTest(1, 8); // core 1 & 4
}
На моей четырехъядерной системе Intel Q6600 (в которой iirc имеет два набора ядер, каждый из которых использует кэш L2 - все равно объяснил бы результаты;)) я получил следующие результаты:
Выполнение теста в течение 5000 миллисекунд со сродством 1 и 2 Поток 00002000: 351883 ошибок Поток 00001000: 343523 ошибок Выполнение теста в течение 5000 миллисекунд со сродством 1 и 4 Поток 00001000: 48073 ошибок Поток 00002000: 59813 ошибок Выполнение теста в течение 5000 миллисекунд со сродством 4 и 8 Поток 00002000: 337199 ошибок Поток 00001000: 335467 ошибок Выполнение теста в течение 5000 миллисекунд со сродством 1 и 8 Тема 00001000: 55736 ошибок Поток 00002000: 72441 ошибок