Почему моя переменная std :: atomic <int>не является поточно-ориентированной? - PullRequest
1 голос
/ 21 октября 2019

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

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;
}

Ответы [ 3 ]

6 голосов
/ 21 октября 2019

Раньше я думал, что мог бы использовать 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) или операторные перегрузки (например, *). 1036 * или operator +=. Подробности см. В std :: atomic

Обновление

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

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

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

  1. Поток X выполняет шаги с 1 по 5, увеличивая a с 50 до 51 и возвращая значение 51 из a.load()
  2. ThreadY выполняет шаги с 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 упоминается блокировка, введенная в C ++17 «… используется для предотвращения гонки данных, когда несколько потоков читают, записывают, позиционируют или запрашивают позицию потока.»В случае двух потоков, одновременно борющихся за эту блокировку, мы все еще не можем сказать, какой из них победит.

3 голосов
/ 21 октября 2019

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

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

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

2 голосов
/ 26 октября 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, и его перезагрузка,Я не уверен, что есть правдоподобный способ, чтобы это произошло здесь без нескольких потоков, считывающих одинаковое количество, которое будет обнаружено.

Store + reload, даже хранилище seq-cst, которое должно очищатьсяБуфер хранилища, прежде чем он сможет перезагрузиться, очень быстр по сравнению с printf, выполняющим системный вызов write(). (Строка формата включает новую строку, и я не перенаправил вывод в файл, так что stdout буферизован строкойи не может просто добавить строку в буфер.)

(write() системные вызовы одного и того же дескриптора файла сериализуются в POSIX: write(2) является атомарным. Также, printf(3) сам по себе является поточно-ориентированным в GNU / Linux, как того требует C ++ 17 и, вероятно, POSIX задолго до этого.)

Блокировка Stdio в printf оказываетсядостаточно сериализации почти во всех случаях: поток, который только что разблокировал stdout и оставил printf, может сделать атомарный инкремент и затем попытаться снова взять блокировку stdout.

Все остальные потоки были заблокированы, пытаясь получить блокировку на stDOUT. Один (другой?) Поток может проснуться и захватить блокировку на stdout, но для его приращения, чтобы состязаться с другим потоком, он должен будет войти и выйти из printf и загрузить a в первый раз, прежде чем этот другой поток выполнит свою a = ... seq-cst store.

Это не значит, что это действительно безопасно

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

Мой рабочий стол имеет 8 логических ядер, поэтому для каждого потока было достаточно, чтобы получить его, не прибегая к расписанию. (Хотя обычно это обычно происходит при вводе-выводе или при ожидании блокировки).


При наличии sleep весьма вероятно, что несколько потоков пробудятсяпочти в одно и то же время и на практике соревнуются друг с другом на реальном оборудовании x86. Я думаю, что это так долго, что степень детализации таймера становится фактором. Или что-то в этом роде.


Перенаправление вывода в файл

С открытым stdout для файла, не являющегося TTY, он полностью буферизуется, а не буферизуется строкой , и не всегда выполняет системный вызов, удерживая блокировку стандартного вывода.

(Я получил 17-мегабайтный файл в / tmp от удара по control-C через доли секунды после выполнения ./a.out > output.)

Это делает процесс достаточно быстрым, чтобы потоки фактически состязались друг с другом на практике, показывая ожидаемые ошибки повторяющихся значений. (Поток читает a, но теряет владение строкой кэша до того, как сохраняет (tmp)+1, в результате чего два или более потоков выполняют одинаковое приращение. И / или несколько потоков читают одно и то же значение при перезагрузке a после очистки ихбуфер хранения.)

1228589 уникальных строк (sort -u | wc), но общий вывод составляет
1291035 всего строк. Таким образом, ~ 5% выходных строк были дубликатами.

Я не проверял, дублировалось ли обычно одно значение несколько раз или обычно это был только один дубликат. Или как далеко назад значение когда-либо прыгнуло. Если поток оказался остановленным обработчиком прерываний после загрузки, но перед сохранением val+1, это может быть довольно далеко. Или, если он действительно спал или блокировался по какой-либо причине, он мог бы перематываться бесконечно далеко.

...