Что происходит, если два потока читают и записывают один и тот же кусок памяти

Насколько я понимаю, если два потока читают из одного куска памяти, и ни один поток не пишет в эту память, то операция безопасна. Однако я не уверен, что произойдет, если один поток читает, а другой пишет. Что случилось бы? Результат не определен? Или чтение будет устаревшим? Если устаревшее чтение не является проблемой, нормально ли иметь несинхронизированное чтение-запись в переменную? Или возможно, что данные будут повреждены, и ни чтение, ни запись не будут правильными, и в этом случае всегда следует синхронизировать данные?

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

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 ошибок
Другие вопросы по тегам