Программирование C++МногопоточностьРазработчик C++ системного программного обеспечения

При разработке lock free счётчика почему одиночная попытка compare exchange weak может привести к ошибке, д...

При разработке lock-free счётчика почему одиночная попытка compare_exchange_weak может привести к ошибке, даже если значение не изменилось?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

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

При неуспехе функция обновляет переданный параметр ожидаемым фактическим значением атомика. Следующая итерация использует это значение для новой попытки.

Исторический контекст

Атомарное сравнение с обменом стало базовым строительным блоком неблокирующих алгоритмов: оно позволяет изменить значение только при сохранении ожидаемого состояния. Такой механизм решает задачу согласованного обновления общей переменной без обычного мьютекса.

В C++ существуют strong и weak варианты операции. weak допускает ложный отказ, что удобно для реализаций на архитектурах, где низкоуровневая операция может временно потерять гарантию успешного сравнения без фактического изменения значения.

Постановка проблемы

Если использовать compare_exchange_weak один раз и считать любой отказ доказательством изменения значения другим потоком, алгоритм может преждевременно завершиться. Для счётчика это означает потерю обновления, а для более сложного алгоритма — неверный переход состояния.

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

Подробное решение

compare_exchange_weak атомарно сравнивает значение объекта с expected. При совпадении записывается новое значение и операция возвращает true; при несовпадении либо при разрешённом ложном отказе возвращается false.

При неуспехе expected получает фактическое значение атомика. Это позволяет повторить вычисление нового значения относительно свежего состояния:

#include <atomic> std::atomic<int> counter{0}; void increment() { int expected = counter.load(std::memory_order_relaxed); while (!counter.compare_exchange_weak( expected, expected + 1, std::memory_order_relaxed, std::memory_order_relaxed)) { } }

В примере каждая успешная попытка является атомарным чтением-изменением-записью. При конкуренции один поток может проиграть гонку или получить ложный отказ, но цикл повторит вычисление относительно обновлённого expected.

Для простого счётчика обычно предпочтительнее fetch_add: он короче и непосредственно выражает требуемую операцию. Цикл с CAS нужен, когда новое состояние вычисляется сложнее, чем прибавление фиксированного значения.

compare_exchange_strong не допускает ложного отказа, но всё равно может вернуть false, если значение действительно изменилось. В короткой одиночной попытке strong часто удобнее; в цикле weak обычно подходит естественнее. Ни один вариант не гарантирует отсутствия голодания или ограниченного времени завершения: при высокой конкуренции CAS-цикл может долго повторяться.

Порядок памяти следует выбирать отдельно от типа CAS. В примере используется relaxed, потому что счётчик сам по себе не публикует и не защищает другие данные. Если CAS одновременно публикует состояние объекта, потребуются подходящие release/acquire-гарантии.

Ситуация из практики

В очереди задач разработчик заменил атомарное увеличение статистического счётчика на одну попытку compare_exchange_weak. Под нагрузкой часть попыток завершалась отказом, и итоговое число обработанных задач оказывалось меньше фактического.

Рассматривались три варианта. compare_exchange_strong в цикле сохранял корректность, но не устранял саму необходимость повторов. fetch_add был проще и эффективнее для обычного счётчика, но не подходил для условного обновления сложного состояния. Одиночный weak был отвергнут как некорректный.

Выбрали fetch_add для статистики и CAS-цикл для состояния, где обновление зависело от нескольких полей. Это устранило потери обновлений и сохранило неблокирующее обновление там, где оно действительно требовалось.

Что кандидаты часто упускают

  1. Можно ли считать любой отказ compare_exchange_weak признаком вмешательства другого потока?

Нет. Отказ может быть ложным, даже если атомик не менялся. Поэтому смысл weak раскрывается только в контексте повторной попытки или алгоритма, который допускает добровольный отказ.

  1. Что происходит с параметром expected после неудачного CAS?

Он заменяется текущим значением атомарного объекта, если операция неуспешна. Это важно: новое значение нужно использовать для повторного вычисления желаемого состояния, а не продолжать применять устаревшее ожидание.

  1. Достаточно ли цикла CAS для гарантии прогресса каждого потока?

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