Скажем, у меня есть std::atomic с именем num:
std::atomic<double> num{3.1415};
поскольку С++ не полностью поддерживает арифметические операции для атомов, за исключением «целочисленных типов», я не могу сделать:
num *= 10;
Вместо этого мне придется сделать
num.store( num.load() * 10 );
Есть ли риск гонки данных? например, после num.load(), но до num.store(), возможно ли, что другой поток уже обновил атомарный код?
@Питер: позвольте мне не согласиться... то, что хранилище и загрузка являются атомарными и упорядоченными, не означает, что нет состояния гонки. Относительное время доступа для чтения/записи из других потоков может повлиять на логическую корректность использования (обновления и значения, считанные из) num
: это приводит к состоянию гонки. Точнее, если другой поток обновляет num после того, как поток «*=10» выполнил загрузку и до того, как он выполнил сохранение, то обновление другого потока затирается/перезаписывается. Например, два потока, пытающиеся обновить *=10, могут в конечном итоге увеличить num только в 10 раз, а не в 100.
Нет неопределенного поведения в гонке данных, но отдельно-атомарная загрузка и сохранение не создают атомарный RMW, так что да, несколько потоков, выполняющих этот код, будут наступать друг на друга. (Люди называют это состоянием гонки или гонкой данных, хотя стандарт C++ не определяет это как гонку данных, поскольку переменная является атомарной). В любом случае, целые операторы C++ не являются атомарными транзакциями! Один оператор может изменять несколько отдельных атомарных и неатомарных переменных, что большинство аппаратных средств не может выполнять как транзакцию без блокировки.
@TonyDelroy, Когда вы разговариваете с людьми, занимающимися C++, «состояние гонки» означает нечто иное, чем то, что оно означает для остального мира. В C++ понятия «состояние гонки» и «гонка данных» означают одно и то же, а в приведенном здесь примере их нет. Эта программа имеет только два возможных результата, оба из которых четко определены. Конечным значением переменной будет то, которое было сохранено тем или иным потоком. Даже если вы не можете знать, что именно, люди в C++ не говорят «состояние гонки». Они говорят это только тогда, когда программа нарушает определенные правила таким образом, что результат становится совершенно неопределенным.
@SolomonSlow: «когда вы разговариваете с людьми, работающими на C++» — возможно, с теми, с кем вы разговаривали, но не с теми, с кем я разговаривал. Я согласен с точкой зрения Питера Кордеса, хотя и противопоставляю специфическую для C++ терминологию «гонки данных» общему понятию «состояния гонки» для разработчиков/потоков.
Есть ли риск гонки данных?
Да. На самом деле, причина, по которой вы не можете сделать это с *=
, заключается в том, что это может заставить вас думать, что это не возможная гонка данных. Заставляя вас явно выполнять load
и store
, вы даете понять, что вы выполняете две операции.
Две последовательные атомарные операции не являются атомарными в целом.
std::atomic
предоставляет compare_exchange_weak
, которого достаточно для реализации fetch_mul
или operator*
, если они захотят. Просто неудобно, что заставляют катать свой. Или, говоря иначе, если бы *=
существовал, это был бы атомный RMW. Итак, ваше первое предложение кажется странным. Возможно, они этого не предоставляют, потому что ни одно реальное оборудование не имеет атомарной формы с одной инструкцией, поэтому только машины LL/SC могут сделать это так просто, как для fetch_add
/+=
. И/или потому, что это требуется реже.
Дело не в том, что это не будет «атомарный процессор с одной инструкцией»; дело в том, что это будет «довольно дорогой атомарный метод», требующий цикла, чтобы увидеть, не изменил ли кто-нибудь значение переменной, когда вы ее впервые прочитали.
fetch_xor
, fetch_and
и т. д. требуют цикла повтора CAS на x86, если вы используете возвращаемое значение. Только fetch_sub
/fetch_add
можно оптимизировать до lock xadd
. Честно говоря, некоторые процессоры не имеют инструкции целочисленного умножения, а циклическая реализация умножения создает большее окно гонки, что потенциально увеличивает количество повторных попыток. Вероятно, это наиболее вероятная причина отсутствия operator*=
/fetch_mul
.
Хотя num.store(num.load() * 10)
все еще имеет состояние гонки, вы можете использовать atomic_compare_exchange , чтобы делать то, что хотите.
Общая идея использования операций сравнения и обмена (иногда называемых сравнением и обменом) заключается в том, что в псевдокоде
do {
last-value-read = atomic-read-of-memory-location
desired-value = last-value-read * 10
} while (atomic-compare-and-exchange(atomic-update-to=desired_value,
only-if-memory-still=last-value-read));
(Некоторые API, например C++, упрощают задачу, обновляя переменную чтения последнего значения внутри операции сравнения и обмена, если содержимое памяти не соответствует входному значению, поэтому вы просто пересчитываете желаемое значение в цикле. ценить.)
С помощью этого подхода вы можете реализовать свои собственные, более сложные атомарные операции, даже реализовать такие вещи, как блокировки чтения-записи, где определенные биты атомарного значения представляют счетчики и логические флаги....
Я хотел бы добавить к существующим ответам, что
Есть ли риск гонки данных?
Нет, не в соответствии со стандартным определением C++ «гонки данных»:
[intro.races]/21
Выполнение программы содержит гонку данных, если она содержит два потенциально одновременных конфликтующих действия, по крайней мере одно из которых не является атомарным, и ни одно из них не происходит раньше другого, за исключением особого случая для обработчиков сигналов, описанного ниже. Любая такая гонка данных приводит к неопределенному поведению.
Ваша программа эквивалентна:
auto val = num.load() * 10;
// here you could have some more statements
num.store(val);
Итак, со стандартной точки зрения у вас нет гонки данных и неопределенного поведения. Однако,
после num.load(), но до num.store(), возможно ли, что другой поток уже обновил атомарный код?
Абсолютно да. И вы не можете быть уверены, каким будет окончательное значение вашего num
после запуска N потоков. Но это не Неопределённое поведение, вы просто написали программу, которая ведет себя недетерминированным образом.
«Гонка данных» — это не то же самое, что «состояние гонки». Первое — это особый вид UB, который может произойти при доступе к неатомарным переменным из разных потоков. Последнее является общим словом для обозначения ошибок многопоточности.
В вашем коде нет гонки данных UB. У него есть состояние гонки (вы можете получить неправильное число, если другой поток изменит переменную между двумя операциями).
ОП не спрашивал, есть ли в программе гонка данных; он спросил, находится ли он «под угрозой» этого. И это так.
@NicolBolas «И это так». Но это не так? Гонка данных по определению требует наличия неатомарного объекта, но его нет. eel.is/c++draft/intro.races#def:data_race
Чтобы четко ответить на одну часть вопроса, которая является распространенным заблуждением:
Наличие нескольких операций в одном операторе не приводит к их атомарному выполнению.
Если бы это было так, вы бы смогли создавать сколь угодно сложные атомарные операции, включающие любое количество объектов (например, с помощью оператора запятой). Но настоящие машины не имеют возможности выполнить такую задачу. Обычно они предоставляют очень ограниченный набор атомарных операций без блокировок, которые действуют только на один объект, а атомарные функции-члены чтения-изменения-записи std::atomic<T>
предоставляют способ их использования.
C++ хорош в предоставлении программистам возможностей аппаратного обеспечения достаточно переносимым способом, но он не является волшебством и не может делать то, на что базовое оборудование еще не способно.
Спасибо за описание сценария операторов, разделенных запятыми, иначе мне было бы интересно, насколько сложно сделать один оператор атомарным.
Состояние гонки означает, что два потока получают доступ к одной и той же переменной или изменяют ее без упорядочивания (например, поэтому одно обновление может быть прервано другим при частичном завершении). Возможно, что
num
будет обновлено после вызоваnum.load()
и перед вызовомnum.store()
(умножение не является атомарной операцией), но это не является состоянием гонки, поскольку обновленияnum
будут упорядочены. В результате обновление будет потеряно (num
перезаписано)num.store()
.