Другие ответы указывают на неатомарное приращение и различные проблемы. В основном я хочу указать на некоторые интересные практические детали того, что именно мы видим при запуске этого кода в реальной системе. (x86-64 Arch Linux, gcc9.1 -O3, i7-6700k 4c8t Skylake).
Может быть полезно понять, почему определенные ошибки или варианты дизайна приводят к определенному поведению, для устранения неполадок / отладки.
Используйте int tmp = ++a;
, чтобы записать результат fetch_add в локальную переменную вместо повторной загрузки из общей переменной. (И, как говорит 1202ProgramAlarm, вы можете захотеть рассматривать все приращение и печать как атомарную транзакцию, если вы настаиваете на том, чтобы ваши счетчики распечатывались по порядку, а также выполнялись правильно.)
Или вы можете захотеть, чтобы каждый поток записывал значения, которые он видел, в закрытую структуру данных для последующей печати, вместо того, чтобы также сериализовать потоки с printf
во время приращений. (На практике все попытки увеличить одну и ту же атомарную переменную будут сериализовать их в ожидании доступа к строке кэша; ++a
будет идти по порядку, так что вы можете определить по порядку модификации, какой поток прошел в каком порядке.)
Забавный факт: a.store(1 + a.load(std:memory_order_relaxed), std::memory_order_release)
— это то, что вы можете сделать для переменной, которая была записана только одним потоком, но прочитана несколькими потоками. Вам не нужен атомарный RMW, потому что никакой другой поток никогда не модифицирует его. Вам просто нужен потокобезопасный способ публикации обновлений. (Или лучше, в цикле сохранить локальный счетчик и просто .store()
его не загружая из общей переменной.)
Если вы использовали a = ...
по умолчанию для последовательно-согласованного хранилища, вы могли бы также сделать атомарный RMW на x86. Один хороший способ компиляции с атомарным xchg
или mov
+mfence
такой же дорогой (или дороже).
Интересно то, что, несмотря на огромные проблемы с вашим кодом, никакие счетчики не были потеряны или нарушены (нет повторяющихся счетчиков), просто переупорядочена печать. Так что на практике с опасностью не сталкивались из-за других эффектов.
Я попробовал это на своем собственном компьютере и действительно потерял некоторые значения. Но после удаления спящего режима я просто переупорядочил. (Я скопировал и вставил около 1000 строк вывода в файл, и sort -u
для унификации вывода не изменило количество строк. Некоторые поздние отпечатки были перемещены. однако; предположительно, один поток застопорился на некоторое время.) Мое тестирование не проверяло возможность потерянных счетчиков, пропущено, потому что значение не сохраняется в a
, а вместо этого перезагружается. Я не уверен, что это может произойти здесь без нескольких потоков, считывающих один и тот же счет, который будет обнаружен.
Хранение + перезагрузка, даже хранилище seq-cst, которое должно сбросить буфер хранилища перед перезагрузкой, выполняется очень быстро по сравнению с printf
системным вызовом write()
. (строка формата включает новую строку и I не перенаправлял вывод в файл, поэтому stdout буферизуется строкой и не может просто добавить строку в буфер.)
(write()
системные вызовы одного и того же файлового дескриптора сериализуются в POSIX: write(2)
< /a> является атомарным. Кроме того, сам printf(3)
является потоком -safe в GNU/Linux, как того требует C++17 и, возможно, POSIX задолго до этого.)
Блокировки stdio в printf
оказывается достаточно для сериализации почти во всех случаях: поток, который только что разблокировал stdout и оставил printf, может выполнить атомарное приращение, а затем снова попытаться взять блокировку stdout.
Все остальные потоки были заблокированы, пытаясь заблокировать стандартный вывод. Один (другой?) поток может проснуться и взять блокировку на stdout, но для того, чтобы его приращение могло участвовать в гонке с другим потоком, он должен войти и выйти из printf и загрузить a
в первый раз, прежде чем этот другой поток зафиксирует свой a = ...
seq-cst. хранить.
Это не значит, что это на самом деле безопасно
Просто тестирование этой конкретной версии программы (по крайней мере, на x86) не сразу выявляет отсутствие безопасности. Прерывания или изменения расписания, включая конкуренцию со стороны других программ, работающих на той же машине, безусловно, могут заблокировать поток в самое неподходящее время.
Мой рабочий стол имеет 8 логических ядер, поэтому их достаточно для каждого потока, чтобы получить по одному, и не нужно было отменять расписание. (Хотя обычно это происходит при вводе-выводе или при ожидании блокировки).
С sleep
вполне вероятно, что несколько потоков пробуждаются почти в одно и то же время и соревнуются друг с другом на практике на реальном оборудовании x86. Это так долго, что гранулярность таймера становится фактором, я считать. Или что-то типа того.
Перенаправление вывода в файл
Когда stdout
открыт в файле, отличном от TTY, он использует полную буферизацию, а не линейную буферизацию, и не всегда выполняет системный вызов, удерживая блокировку stdout.
(Я получил файл размером 17 МБ в /tmp, нажав Control-C через долю секунды после запуска ./a.out > output
.)
Это делает его достаточно быстрым для того, чтобы потоки фактически состязались друг с другом на практике, показывая ожидаемые ошибки дублирования значений. (Поток считывает a
, но теряет право владения строкой кэша до того, как сохранит (tmp)+1
, в результате чего два или более потоков выполняют одно и то же приращение. И/или несколько потоков считывают одно и то же значение, когда перезагружают a
после очистки своего буфера хранилища.)
1228589
уникальных строк (sort -u | wc
), но общий вывод
всего 1291035
строк. Таким образом, ~ 5% строк вывода были дубликатами.
Я не проверял, обычно ли это было одно значение, дублированное несколько раз, или обычно это был только один дубликат. Или как далеко назад значение когда-либо прыгало. Если поток был остановлен обработчиком прерывания после загрузки, но до сохранения val+1
, это может быть довольно далеко. Или, если он на самом деле спал или блокировался по какой-то причине, он мог перематывать бесконечно далеко.
person
Peter Cordes
schedule
26.10.2019
a.load()
, а затем вызываетprintf
. Между этими двумя вызовами может произойти что угодно (включая другие потоки, вызывающиеa.load
иprintf
. - person David Schwartz   schedule 21.10.2019