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