Атомарные операции в C++: типичные ошибки
std::atomic не делает код автоматически потокобезопасным. Разбираем ошибки с моделями памяти, составными операциями и ложным ощущением защиты — на примерах, которые компилируются, но ведут себя не так, как ждёт автор.

Счётчик обёрнут в std::atomic<int>, инкремент есть, а под нагрузкой значение всё равно расходится с реальным числом событий. Или флаг готовности — атомарный, а поток-читатель видит нули в данных, которые писатель уже проинициализировал. Оба бага компилируются без предупреждений и на одном ядре с -O0 не воспроизводятся. Дальше — четыре класса ошибок, которые дают ровно такие эффекты, с разбором, почему очевидное решение молчит, но не работает. Ориентируюсь на C++17 и новее, компиляторы GCC 13.x / Clang 17.x.
Ошибка 1: считать, что atomic делает потокобезопасной составную операцию
Самое частое заблуждение: раз переменная атомарная, то любое действие с ней атомарно. Атомарным является один вызов метода std::atomic, а не выражение в вашем коде.
#include <atomic>
std::atomic<int> balance{100};
// НЕПРАВИЛЬНО: три отдельные атомарные операции
void withdraw_broken(int amount) {
if (balance.load() >= amount) { // 1. чтение
balance.store(balance.load() - amount); // 2. чтение и 3. запись
}
}
Между load() в условии и store() другой поток успевает изменить balance. Проверка «хватает ли средств» и списание не связаны. Два потока могут одновременно пройти проверку при остатке 100 и оба списать по 100 — уйдёте в минус.
Атомарным должен быть весь цикл «прочитать — проверить — записать». Для этого есть compare_exchange:
bool withdraw(int amount) {
int current = balance.load(std::memory_order_relaxed);
do {
if (current < amount) return false;
// пытаемся заменить current на current - amount;
// если между load и здесь balance изменился,
// compare_exchange_weak обновит current и вернёт false
} while (!balance.compare_exchange_weak(current, current - amount));
return true;
}
compare_exchange_weak при неудаче сам записывает актуальное значение в current, поэтому в цикле повторное чтение не нужно. Слово weak допускает ложные срабатывания (spurious failure) на некоторых платформах — именно поэтому его используют внутри цикла, где повторная попытка ничего не стоит.
Если операция состоит больше чем из одного обращения к atomic, атомарность отдельных обращений вам ничем не поможет. Нужна либо одна инструкция вроде fetch_add, либо CAS-цикл, либо мьютекс.
Когда достаточно fetch_add
Простой счётчик без проверки условия не требует CAS. counter.fetch_add(1) — одна атомарная операция, здесь ошибки нет. Проблема возникает, когда вы разбиваете её на load и store вручную.
Ошибка 2: думать, что seq_cst — это просто дефолт «на всякий случай»
По умолчанию все операции над std::atomic используют memory_order_seq_cst — самый строгий порядок. Он корректен всегда, и в этом ловушка: код работает, поэтому про модель памяти не думают вообще. Проблема всплывает при попытке оптимизировать — люди начинают ставить memory_order_relaxed туда, где он ломает логику.
std::atomic<bool> ready{false};
int payload = 0; // обычная переменная
// Поток-писатель
void producer() {
payload = 42;
ready.store(true, std::memory_order_relaxed); // ОШИБКА
}
// Поток-читатель
void consumer() {
while (!ready.load(std::memory_order_relaxed)) {}
// здесь payload может быть ещё 0
use(payload);
}
С relaxed нет никакой гарантии, что запись payload = 42 станет видна читателю до того, как он увидит ready == true. Компилятор и процессор вправе переставить эти записи местами. Читатель увидит флаг, но прочитает старое значение payload.
Правильно — пара release/acquire. Она устанавливает отношение «happens-before»: всё, что писатель сделал до store(release), гарантированно видно читателю после успешного load(acquire).
void producer() {
payload = 42;
ready.store(true, std::memory_order_release);
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) {}
use(payload); // payload гарантированно 42
}
Ошибка 3: полагать, что atomic всегда lock-free
Атомарность не означает, что операция реализована без блокировок. Для типов, которые не помещаются в машинное слово или не поддерживаются процессором напрямую, стандартная библиотека вставляет скрытый мьютекс.
#include <atomic>
#include <iostream>
struct BigStruct { double a, b, c, d; }; // 32 байта
int main() {
std::atomic<BigStruct> s;
std::cout << std::boolalpha
<< s.is_lock_free() << "\n"; // вероятно false
}
Если вы рассчитывали на lock-free поведение в горячем пути, а получили скрытую блокировку, производительность просядет непредсказуемо. Проверяйте is_lock_free() в рантайме или std::atomic<T>::is_always_lock_free на этапе компиляции (доступно с C++17):
static_assert(std::atomic<int>::is_always_lock_free,
"atomic<int> должен быть lock-free на этой платформе");
Отдельная тонкость: до C++20 std::atomic<T> для типа с не тривиальным конструктором мог вести себя неожиданно. С C++20 добавили std::atomic_ref — он даёт атомарный доступ к уже существующему обычному объекту, не оборачивая его целиком. Это отдельная тема, здесь только отмечаю, что для внешних буферов это часто более подходящий инструмент.
Ошибка 4: volatile вместо atomic
В C++ volatile не имеет отношения к многопоточности. Он запрещает компилятору оптимизировать обращения к памяти (нужно для memory-mapped регистров железа), но не даёт ни атомарности, ни барьеров памяти, ни гарантий видимости между потоками.
volatile bool stop = false; // НЕ потокобезопасно для межпоточной синхронизации
Такой флаг на некоторых платформах случайно работает, а на других — данные теряются или зависают в цикле навсегда, потому что процессор кеширует значение. Для флага остановки потока используйте std::atomic<bool>. С C++20 для флагов, где нужна ещё и эффективная блокировка/пробуждение, есть std::atomic_flag с методами wait() и notify_one().
Сравнение
| Инструмент | Атомарность | Барьеры памяти | Назначение |
|---|---|---|---|
volatile T | нет | нет | доступ к аппаратным регистрам, longjmp |
std::atomic<T> | да | да (настраиваются) | межпоточный обмен значениями |
| обычная переменная + mutex | да (в критической секции) | да | составные операции, большие данные |
Как выбирать между подходами
- Один счётчик или флаг, простая операция —
std::atomicсfetch_add/store/load. - Проверка условия и изменение вместе — CAS-цикл через
compare_exchange_weak. - Несколько связанных переменных или сложная инвариантность — обычные данные под
std::mutex. Не пытайтесь собрать lock-free структуру из десятка atomic-полей, если не готовы доказывать её корректность. - Доступ к железу —
volatile, и только для этого.
Что делать дальше
Соберите сомнительный многопоточный код под ThreadSanitizer: -fsanitize=thread у GCC и Clang. Он ловит гонки данных в рантайме, включая пропущенные барьеры памяти, которые глазами не видны. Начинайте с дефолтного seq_cst и переходите на acquire/release или relaxed только после того, как убедились в корректности и увидели на профиле, что синхронизация действительно стала узким местом. Для глубокого понимания порядков памяти — раздел про memory order в cppreference и книга «C++ Concurrency in Action».
Транскрибатор встреч: Запись созвона или диктофона — в текст с разделением по спикерам, протоколом и списком задач.
Спросить за 25 ₽Источники: std::atomic — cppreference.com, std::memory_order — cppreference.com
Частые вопросы
Если у меня C++11, а не C++17 — что из этого не сработает?
Почему мой атомарный счётчик всё равно даёт неправильную сумму под нагрузкой?
Можно ли всегда использовать memory_order_seq_cst и не думать о моделях памяти?
Гонка данных не воспроизводится в тестах — значит её нет?
atomic_ref — замена std::atomic?
Достаточно ли пометить общий флаг как volatile для остановки потока?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.