Почему моя переменная std::atomic‹int› не является потокобезопасной?

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

value 48
value 49
value 50
value 54
value 51
value 52
value 53

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

Раньше я думал, что могу использовать std::atomic без мьютекса для решения проблемы увеличения счетчика многопоточности, но это не выглядело так.

Я, наверное, неправильно понял, что такое атомарный объект. Может кто-нибудь объяснить?

void
inc(std::atomic<int>& a)
{
  while (true) {
    a = a + 1;
    printf("value %d\n", a.load());
    std::this_thread::sleep_for(std::chrono::milliseconds(1000));
  }
}

int
main()
{
  std::atomic<int> a(0);
  std::thread t1(inc, std::ref(a));
  std::thread t2(inc, std::ref(a));
  std::thread t3(inc, std::ref(a));
  std::thread t4(inc, std::ref(a));
  std::thread t5(inc, std::ref(a));
  std::thread t6(inc, std::ref(a));

  t1.join();
  t2.join();
  t3.join();
  t4.join();
  t5.join();
  t6.join();
  return 0;
}

person Shuo Feng    schedule 20.10.2019    source источник
comment
а = а + 1; Это не может быть атомарным независимо от используемого типа. Используйте fetch_add   -  person Sopel    schedule 21.10.2019
comment
Или используйте приращение до/после.   -  person Shawn    schedule 21.10.2019
comment
У вас нет кода для размещения операторов печати в каком-либо определенном порядке. Каждый поток вызывает a.load(), а затем вызывает printf. Между этими двумя вызовами может произойти что угодно (включая другие потоки, вызывающие a.load и printf.   -  person David Schwartz    schedule 21.10.2019


Ответы (3)


Раньше я думал, что могу использовать std::atomic без мьютекса для решения проблемы увеличения счетчика многопоточности, но это не выглядело так.

Вы можете, просто не так, как вы это закодировали. Вы должны подумать о том, где происходит атомарный доступ. Рассмотрим эту строку кода…

a = a + 1;
  1. Сначала значение a извлекается атомарно. Допустим, полученное значение равно 50.
  2. Мы добавляем единицу к этому значению, получая 51.
  3. Наконец, мы атомарно сохраняем это значение в a с помощью оператора =.
  4. a становится 51
  5. Мы атомарно загружаем значение a, вызывая a.load()
  6. Мы печатаем значение, которое мы только что загрузили, вызвав printf()

Все идет нормально. Но между шагами 1 и 3 некоторые другие потоки могли изменить значение a, например, на значение 54. Таким образом, когда шаг 3 сохраняет 51 в a, оно перезаписывает значение 54, давая вам вывод, который вы видеть.

Как предлагают @Sopel и @Shawn в комментариях, вы можете атомарно увеличивать значение в a, используя одну из соответствующих функций (например, fetch_add) или перегруженные операторы (например, operator ++ или operator +=). См. документация std::atomic для получения подробной информации.

Обновить

Я добавил шаги 5 и 6 выше. Эти шаги также могут привести к результатам, которые могут выглядеть некорректно.

Между сохранением на шаге 3 и вызовом tp a.load() на шаге 5 другие потоки могут изменять содержимое a. После того, как наш поток сохранит 51 в a на шаге 3, он может обнаружить, что a.load() возвращает какое-то другое число на шаге 5. Таким образом, поток, который установил a в значение 51, может не передать значение 51 в printf().

Другой источник проблем заключается в том, что ничто не координирует выполнение шагов 5 и 6 между двумя потоками. Так, например, представьте два потока X и Y, работающих на одном процессоре. Один из возможных порядков выполнения может быть таким…

  1. Поток X выполняет шаги с 1 по 5 выше, увеличивая a с 50 до 51 и возвращая значение 51 из a.load().
  2. Поток Y выполняет шаги с 1 по 5 выше, увеличивая a с 51 до 52 и возвращая значение 52 из a.load().
  3. Поток Y выполняет printf(), отправляя 52 на консоль
  4. Поток X выполняет printf(), отправляя 51 на консоль

Теперь мы напечатали на консоли 52, а затем 51.

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

В многопроцессорной системе потоки X и Y выше могут вызывать printf() в один и тот же момент (или в течение нескольких тиков одного и того же момента) на двух разных процессорах. Мы не можем предсказать, какой вывод printf() появится в консоли первым.

Примечание В документации для printf упоминается введенная блокировка в С++ 17 «… используется для предотвращения гонок данных, когда несколько потоков читают, пишут, позиционируют или запрашивают позицию потока». В случае двух потоков, одновременно борющихся за эту блокировку, мы все еще не можем сказать, какой из них победит.

person Frank Boyne    schedule 20.10.2019
comment
Спасибо за ответ. Я попробовал как оператор ++, так и метод fetch_add, и все еще вижу некоторый беспорядочный вывод, может быть, это то, что @David Schwartz упомянул в комментарии, что что-то произошло между printfs? - person Shuo Feng; 21.10.2019
comment
Да, атомарное увеличение a не является полным решением. Я расширил ответ, включив в него проблемы, возникающие между магазином и вызовом a.load(); между вызовом a.load() и вызовом printf(); и между одновременными вызовами printf(). Надеюсь это поможет. - person Frank Boyne; 21.10.2019

Помимо того, что приращение a выполняется неатомарно, выборка значения для отображения после приращения не является атомарной по отношению к приращению. Возможно, что один из других потоков увеличивает a после того, как текущий поток увеличил его, но до выборки отображаемого значения. Это может привести к тому, что одно и то же значение будет показано дважды, а предыдущее значение будет пропущено.

Другая проблема заключается в том, что потоки не обязательно выполняются в том порядке, в котором они были созданы. Поток 7 может выполнить свой вывод раньше потоков 4, 5 и 6, но после того, как все четыре потока увеличат a. Поскольку поток, выполнивший последнее приращение, отображает свой вывод раньше, в итоге вы получите вывод, не являющийся последовательным. Скорее всего, это произойдет в системе с менее чем шестью аппаратными потоками, доступными для работы.

Добавление небольшого перерыва между созданием различных потоков (например, sleep_for(10)) сделало бы это менее вероятным, но все же не устранило бы эту возможность. Единственный верный способ упорядочить вывод — использовать какое-то исключение (например, мьютекс), чтобы гарантировать, что только один поток имеет доступ к приращению и выходному коду, и рассматривать как приращение, так и выходной код как одну транзакцию, которая должна выполняться. вместе, прежде чем другой поток попытается выполнить приращение.

person 1201ProgramAlarm    schedule 20.10.2019

Другие ответы указывают на неатомарное приращение и различные проблемы. В основном я хочу указать на некоторые интересные практические детали того, что именно мы видим при запуске этого кода в реальной системе. (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