В практической задаче два потока увеличивают одну атомарную переменную через отдельное чтение и запись: гарантирует ли атомарность этих операций корректный общий счётчик?
Нет. Атомарность отдельного чтения и отдельной записи не делает составную операцию «прочитать, увеличить, записать» неделимой. Потоки могут прочитать одно и то же значение, затем оба записать одинаковый результат, поэтому часть увеличений потеряется.
Для общего счётчика нужен неделимый RMW-оператор, например fetch_add, либо цикл на compare_exchange. Если счётчик не используется для публикации других данных, для такого изменения обычно достаточно порядка relaxed.
Атомики появились в стандарте C++ для безопасного обмена небольшими значениями между потоками без обязательного использования блокировок. Они решают проблему гонки данных на уровне отдельных операций чтения и записи и позволяют строить более сложные неблокирующие алгоритмы.
Однако атомарность не означает, что произвольная последовательность атомарных операций автоматически становится одной неделимой транзакцией. Для типичных составных действий стандарт предоставляет специальные операции read-modify-write — например, fetch_add, exchange и compare_exchange.
Пусть счётчик равен 10. Первый поток читает 10, второй поток также читает 10. После этого оба вычисляют 11 и записывают 11. Итог равен 11, хотя логически были выполнены два увеличения и ожидалось значение 12.
При этом доступа к неатомарному объекту нет: все операции над переменной могут быть атомарными, и формальной гонки данных не возникает. Ошибка относится к логике составной операции — между чтением и записью другой поток может изменить значение.
fetch_add выполняет чтение старого значения, вычисление нового и запись результата как одну атомарную операцию read-modify-write. Если несколько потоков одновременно вызывают её, каждый вызов применяется к актуальному значению в некотором едином порядке модификаций, поэтому обновления не теряются.
Порядок relaxed гарантирует атомарность самого счётчика и согласованность его модификаций, но не устанавливает отношение happens-before для других объектов. Поэтому он подходит для статистики, числа обработанных элементов или приблизительного счётчика, если чтение счётчика не должно публиковать результаты другой работы.
Если при увеличении нужно одновременно проверить условие, используется compare_exchange в цикле: поток пытается заменить ожидаемое старое значение новым, а при неудаче получает актуальное значение и повторяет вычисление. Такой вариант гибче, но сложнее и потенциально может долго повторяться при высокой конкуренции.
Мьютекс также решает задачу: чтение, изменение и запись выполняются внутри критической секции. Его преимущество — возможность защищать несколько связанных переменных одной операцией; недостатки — блокировки, возможное вытеснение потока и риск взаимной блокировки при ошибочном проектировании.
В сервисе несколько рабочих потоков увеличивали число обработанных сообщений. Первоначально использовалась атомарная переменная, но операция была разделена на загрузку значения, вычисление нового и сохранение. Под нагрузкой итоговая статистика систематически была меньше фактического числа сообщений.
Рассматривались три варианта. Мьютекс был простым и позволял бы вместе обновлять связанные метрики, но добавлял конкуренцию за одну блокировку. Цикл compare_exchange давал возможность встроить дополнительные условия, однако усложнял код и мог многократно повторяться. fetch_add был самым коротким и специализированным решением для простого счётчика.
Выбрали fetch_add с memory_order_relaxed, поскольку значение использовалось только для статистики, а публикация данных через счётчик не требовалась. Потеря обновлений исчезла, а синхронизация не добавляла лишних ограничений для остальных операций.
Вопрос: Возникает ли гонка данных, если отдельные чтения и записи выполняются над std::atomic, но логический результат счётчика неверен?
Ответ: Нет, в стандартном смысле гонки данных между этими доступами нет: атомарный объект предназначен для безопасного одновременного доступа. Но корректность программы шире отсутствия гонки данных. Последовательность из нескольких атомарных операций может нарушать инвариант, потому что другой поток имеет право изменить объект между ними.
Вопрос: Достаточно ли заменить memory_order_relaxed на memory_order_seq_cst, чтобы отдельные загрузка и сохранение стали корректным увеличением?
Ответ: Нет. seq_cst усиливает порядок видимости атомарных операций относительно других потоков, но не объединяет две отдельные операции в одну. Потоки всё равно могут выполнить загрузку одного значения, а затем потерять одно из обновлений. Требуется именно RMW-операция либо блокировка.
Вопрос: Когда для счётчика оправдан fetch_add с relaxed, а когда нужен более сильный порядок памяти?
Ответ: relaxed достаточен, когда счётчик является самостоятельным атомарным числом: например, используется для статистики, подсчёта попыток или выбора номера без публикации содержимого других объектов. Более сильный порядок нужен, если операция над счётчиком должна участвовать в передаче владения или публикации данных.
Например, если один поток записывает объект, затем увеличивает атомарный флаг, а другой после наблюдения этого флага читает объект, одного relaxed недостаточно для установления требуемой видимости. В такой схеме применяют согласованные release и acquire либо другой явно спроектированный механизм синхронизации; выбор порядка определяется протоколом обмена, а не самим фактом инкремента.