С++: std:: atomic <bool> и volatile bool

Я просто читаю С++ concurrency в книге действий Энтони Уильямса. Это классический пример с двумя потоками, один - с данными, другой - с данными и с A.W. написал этот код довольно ясно:

std::vector<int> data;
std::atomic<bool> data_ready(false);

void reader_thread()
{
    while(!data_ready.load())
    {
        std::this_thread::sleep(std::milliseconds(1));
    }
    std::cout << "The answer=" << data[0] << "\n";
}

void writer_thread()
{
    data.push_back(42);
    data_ready = true;
}

И я действительно не понимаю, почему этот код отличается от того, где я бы использовал классический volatile bool вместо атомного. Если бы кто-то мог открыть мой разум по этому вопросу, я был бы благодарен. Спасибо.

Ответ 1

Большая разница в том, что этот код верен, а версия с bool вместо atomic<bool> имеет поведение undefined.

Эти две строки кода создают условие гонки (формально, конфликт), потому что они читают и записывают в одну и ту же переменную:

Читатель

while (!data_ready)

И писатель

data_ready = true;

И условие гонки на нормальной переменной вызывает поведение undefined в соответствии с моделью памяти С++ 11.

Правила найдены в разделе 1.10 Стандарта, наиболее релевантным является:

Два действия потенциально параллельны, если

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

Выполнение программы содержит гонку данных, если она содержит два потенциально параллельных конфликтных действия, по крайней мере один из которых не является атомарным, и не происходит до другого, за исключением специального случая для обработчиков сигналов, описанных ниже. Любая такая гонка данных приводит к поведению undefined.

Вы можете видеть, что переменная atomic<bool> имеет большое значение для этого правила.

Ответ 2

"Классический" bool, как вы выразились, не будет работать надежно (если вообще). Одна из причин этого заключается в том, что компилятор мог (и, скорее всего, делать, по крайней мере, с включенными оптимизациями) загружать data_ready только один раз из памяти, потому что нет указаний на то, что он когда-либо изменяется в контексте reader_thread.

Вы можете обойти эту проблему, используя volatile bool для принудительной загрузки ее каждый раз (что, вероятно, будет работать), но это все равно будет undefined поведение в отношении стандарта С++, поскольку доступ к переменной не синхронизирован ни атома.

Вы можете принудительно выполнить синхронизацию с помощью средств блокировки из заголовка mutex, но это приведет к излишним издержкам (в вашем примере) (следовательно, std::atomic).


Проблема с volatile заключается в том, что она гарантирует только то, что инструкции не опущены, и порядок заказов сохраняется. volatile не гарантирует защитный барьер для обеспечения согласованности кеша. Это означает, что writer_thread на процессоре A может записать значение в кэш (и, возможно, даже в основную память) без reader_thread на процессоре B, увидев его, потому что кеш процессора B не согласуется с кешем процессор A. Для более подробного объяснения см. барьер памяти и согласованность кеша в Википедии.


Могут возникнуть дополнительные проблемы с более "сложными" выражениями, а затем x = y (т.е. x += y), которые потребуют синхронизации через блокировку (или в этом простом случае атомный +=), чтобы обеспечить значение x не изменяется во время обработки.

x += y например, на самом деле:

  • прочитайте x
  • вычислить x + y
  • вернуть результат обратно в x

Если при вычислении происходит переход контекста к другому потоку, это может привести к чему-то вроде этого (2 потока, оба выполняющие x += 2; предполагая x = 0):

Thread A                 Thread B
------------------------ ------------------------
read x (0)
compute x (0) + 2
                 <context switch>
                         read x (0)
                         compute x (0) + 2
                         write x (2)
                 <context switch>
write x (2)

Теперь x = 2, хотя было два вычисления += 2. Этот эффект называется разрывом.

Ответ 3

Ответ Бена Войгта совершенно верный, но немного теоретический, и, как меня спросил коллега "что это значит для меня", я решил попробовать свою удачу с чуть более практичным ответом.

С вашим образцом может возникнуть следующая "самая простая" проблема оптимизации:

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

Итак, для writer_thread и (volatile) bool

data.push_back(42);
data_ready = true;

и

data_ready = true;
data.push_back(42);

эквивалентны.

В результате получается, что

std::cout << "The answer=" << data[0] << "\n";

может выполняться без нажатия каких-либо значений в данные.

Атомный bool предотвращает такую ​​оптимизацию, поскольку определение не может быть переупорядочено. Существуют флаги для атомных операций, которые позволяют операторам перемещаться перед операцией, но не обратно, и наоборот, но для этого требуются действительно расширенные знания о вашей структуре программирования и проблемах, которые это может вызвать...