1. Зачем нужен «словарь операций» std::atomic
Если открыть справочник по <atomic>, можно быстро получить ощущение, что вы случайно поступили на факультет заклинаний: fetch_add, exchange, compare_exchange_weak, какие-то «expected», какие-то “spurious”… И это нормально. В этой лекции мы сделаем ровно одну вещь: соберём понятную карту операций и научимся выбирать их по смыслу, не превращая код в магический ритуал.
Чтобы примеры не были оторванными от жизни, будем продолжать наш учебный мини-проект JobRunner: консольное приложение, которое запускает несколько рабочих потоков и обрабатывает «задачи». Очередь задач и ожидание у нас уже могут быть на std::mutex + std::condition_variable (это проще и честнее для очередей), а вот статистику и небольшие флаги состояния мы сегодня сделаем на атомиках.
load() и store() — атомарное чтение и запись
Когда вы впервые видите std::atomic<int>, мозг пытается упростить: «Ну это же просто int, только честный». Увы (или к счастью), это почти так: атомик — это переменная, чтение и запись которой согласованы между потоками и не образуют data race. Поэтому базовые операции — это чтение (load) и запись (store). Они выглядят скучно, но на них держится остальная конструкция.
Пример: атомарный флаг «останавливаемся»
Представим, что наш JobRunner крутит задачи в нескольких потоках, а главный поток в какой-то момент говорит «стоп». Самый простой вариант — атомарный флаг.
#include <atomic>
int main() {
std::atomic<bool> stop{false};
stop.store(true); // записали "останавливаемся"
bool need_stop = stop.load(); // прочитали
(void)need_stop;
}
Главная мысль: store — это атомарная запись, load — атомарное чтение. То есть два потока могут безопасно делать load() параллельно, и один поток может делать store(), пока другой делает load(). Для обычного bool это было бы data race.
Нюанс: присваивание и неявные удобства
У std::atomic<bool> и std::atomic<int> есть операторы, которые выглядят как обычные:
#include <atomic>
int main() {
std::atomic<int> x{0};
x = 10; // по сути store(10)
int v = x; // по сути load()
(void)v;
}
В учебном коде так писать можно, но на практике многие предпочитают load()/store(), потому что это явно показывает: «тут атомик, я делаю атомарную операцию». В многопоточном коде явность — это не занудство, это профилактика бессонницы.
2. RMW-операции: fetch_add и друзья
После load/store следующий важный класс операций — это read-modify-write (RMW): «прочитал → изменил → записал» как одна атомарная операция. Это ключевая вещь: нельзя «склеить» атомарность из двух атомарных операций вручную, если между ними другой поток может вмешаться.
Антипример: инкремент через load + store
Вот код, который выглядит логично, но в многопоточке часто ломает подсчёт:
#include <atomic>
int main() {
std::atomic<int> counter{0};
int old = counter.load();
counter.store(old + 1); // между load и store другой поток мог тоже увеличить
}
Проблема в том, что другой поток мог сделать то же самое между вашими load() и store(). В итоге одно из увеличений потеряется. Это называется “lost update”, и это классика жанра.
Правильный вариант: fetch_add
fetch_add(1) делает «увеличить на 1» атомарно. И ещё важный бонус: она возвращает старое значение.
#include <atomic>
#include <iostream>
int main() {
std::atomic<int> processed{0};
int before = processed.fetch_add(1);
std::cout << before << '\n'; // 0 (если это был первый инкремент)
std::cout << processed.load() << '\n'; // 1
}
Если вам нужно «взять номер задачи», “ticket”, или «получить уникальный id», fetch_add — один из самых простых инструментов.
Какие fetch_* бывают и что они делают
Семейство fetch_* зависит от типа T. Для целых обычно доступны:
| Операция | Смысл | Возвращает |
|---|---|---|
|
прибавить v | старое значение |
|
вычесть v | старое значение |
|
побитовое AND | старое значение |
|
побитовое OR | старое значение |
|
побитовое XOR | старое значение |
В редакторских материалах к черновику стандарта отмечается, что операции fetch_* держат рядом с compare_exchange, потому что их можно рассматривать как частный случай условного обновления состояния. Это хорошая «мысленная модель»: fetch_add — это как CAS, который “всегда пытается сделать прибавление, пока не получится”.
Мини-встраивание в наш JobRunner: статистика без мьютекса
Предположим, каждый worker после обработки задачи увеличивает счётчик processed, а при ошибке — failed.
#include <atomic>
struct Stats {
std::atomic<int> processed{0};
std::atomic<int> failed{0};
};
void on_task_done(Stats& s, bool ok) {
s.processed.fetch_add(1);
if (!ok) {
s.failed.fetch_add(1);
}
}
Это типичный корректный сценарий для атомиков: независимые счётчики, нет сложных инвариантов «между ними», просто статистика.
4. exchange() — атомарная замена с возвратом старого значения
Иногда вы хотите сделать не «увеличить», а «заменить значение» так, чтобы никто не проскочил между чтением и записью, и при этом вам важно знать, что было до замены. Для этого есть exchange(new_value): атомарно записывает new_value и возвращает старое значение.
Пример: «кто первый включил режим»
Допустим, в JobRunner есть режим “verbose logging”, и мы хотим, чтобы сообщение "Verbose enabled" напечаталось ровно один раз, даже если несколько потоков пытаются «включить».
#include <atomic>
#include <iostream>
int main() {
std::atomic<bool> verbose{false};
bool was = verbose.exchange(true);
if (!was) {
std::cout << "Verbose enabled\n"; // Verbose enabled
}
}
Если was было false, значит вы первый, кто поставил true. Если was уже было true, значит кто-то включил раньше.
Почему это лучше, чем if (!load()) store(true)
Потому что вот такой код не атомарен как «проверить-и-установить»:
#include <atomic>
void enable(std::atomic<bool>& flag) {
if (!flag.load()) { // другой поток мог изменить между строками
flag.store(true);
}
}
Если два потока одновременно увидят false, оба сделают store(true) и оба решат, что они «первые». exchange решает это красиво и коротко.
5. CAS: compare_exchange_* — условное обновление
Если fetch_add — это удобная кнопка “+1”, то CAS (compare-and-swap) — это универсальный строительный блок. Он позволяет сказать атому: «Обнови значение только если оно сейчас равно тому, что я ожидаю». Это нужно для конкурентных обновлений, где мы не хотим потерять изменения другого потока.
Базовая идея
CAS — это операция вида:
- у нас есть атомик x
- у нас есть переменная expected (ожидаемое значение)
- у нас есть desired (какое значение хотим поставить)
Тогда CAS делает примерно так: «если x == expected, то x = desired и успех; иначе провал».
Важнейший нюанс: expected — вход и выход
Параметр expected передаётся по ссылке. И это не прихоть: при неуспехе CAS записывает в expected фактическое текущее значение атомика. Это очень удобно для циклов.
#include <atomic>
#include <iostream>
int main() {
std::atomic<int> state{0};
int expected = 0;
if (state.compare_exchange_strong(expected, 1)) {
std::cout << "changed\n"; // changed
} else {
std::cout << "not changed, actual=" << expected << '\n';
}
}
Если CAS не прошёл, в expected окажется «что реально было в state», и вы можете принять решение: повторить попытку, выйти, или сделать другой desired.
compare_exchange_strong vs compare_exchange_weak
Существуют две версии:
- compare_exchange_strong — «строгая»: если провалилась, то почти всегда из-за того, что значение действительно не совпало.
- compare_exchange_weak — «слабая»: имеет право иногда провалиться даже если значение совпало (так называемый spurious failure).
Звучит как вредительство, но на некоторых архитектурах “weak” может быть дешевле и лучше ложится на железо. Практическое правило в C++ такое: weak обычно используют в цикле, и это нормально.
Типовой CAS-цикл: инкремент вручную
Покажем инкремент через CAS. В реальности чаще берут fetch_add, но для обучения CAS — отличный пример.
#include <atomic>
#include <iostream>
int main() {
std::atomic<int> x{0};
int old = x.load();
while (!x.compare_exchange_weak(old, old + 1)) {
// если CAS не сработал, old уже обновлён актуальным x
}
std::cout << x.load() << '\n'; // 1
}
Обратите внимание на психологически странный момент: переменная old в цикле «сама меняется» при провале CAS — потому что это и есть контракт expected.
CAS как state machine: переход состояния
В JobRunner можно иметь состояние выполнения: например, 0 — “Idle”, 1 — “Running”, 2 — “Stopping”. И мы хотим разрешить переход “Idle → Running” только одному потоку.
#include <atomic>
bool try_start(std::atomic<int>& st) {
int expected = 0; // ждём Idle
return st.compare_exchange_strong(expected, 1); // ставим Running
}
Если два потока одновременно вызовут try_start, только один получит true. Второй получит false, а в expected у него окажется текущее состояние (скорее всего 1).
CAS-паттерн: атомарный max
Ещё один классический пример: «запомнить максимум из многих потоков». Для этого нет fetch_max (в стандартной базе), но можно собрать через CAS.
#include <atomic>
void update_max(std::atomic<int>& m, int value) {
int cur = m.load();
while (cur < value && !m.compare_exchange_weak(cur, value)) {
// если провалилось, cur обновится актуальным значением m
}
}
Тут важная логика: если cur уже >= value, выходим сразу. Иначе пытаемся поставить value. Если другой поток успел поднять максимум, CAS провалится, cur обновится, и мы проверим условие заново.
Можно мысленно представить это как маленькую блок-схему:
flowchart TD
A[load cur] --> B{cur < value?}
B -- no --> E[выйти]
B -- yes --> C{"CAS(cur -> value) успешен?"}
C -- yes --> E
C -- no --> A
Нюанс: atomic<float> и сравнение
Для целых compare_exchange обычно интуитивен: сравнили числа — совпало/не совпало. Но с float/double появляются особенности вроде NaN и тонких различий представления (например, -0.0 и 0.0). В редакторских заметках к стандарту отдельно упоминают, что вокруг compare_exchange для floating-point были вопросы и уточнения именно из‑за таких случаев.
Практическая рекомендация для новичка: если вам действительно нужно lock-free обновление double, сначала очень чётко продумайте, что значит «равенство» в вашем протоколе. Часто проще хранить не double, а, например, целое число в «милли-единицах», или защищать всё мьютексом.
Как выбрать правильную операцию и не устроить культ CAS
После знакомства с compare_exchange у многих начинается роман с CAS: «Ого, значит можно вообще без мьютексов!». На этом месте полезно сделать вдох и вспомнить, что мьютексы придумали не враги, а инженеры, уставшие чинить нервы. Атомики хороши, когда вы действительно работаете с одной переменной или с очень простым протоколом.
Если вам нужно просто посчитать события, fetch_add даст краткий и понятный код. Если нужно «один раз включить», exchange обычно читается проще, чем CAS. Если нужно условно обновить состояние или реализовать атомарный max, тогда CAS — правильный инструмент. Но если у вас инвариант вида «два поля должны меняться согласованно» (например, count и sum, или очередь плюс индексы), то на базовом уровне чаще выигрывает std::mutex, потому что он защищает критическую секцию, а не отдельную переменную.
И ещё одно бытовое правило: если ваш CAS-цикл разросся, внутри появились сложные вычисления, обращения к контейнерам, вызовы функций с побочными эффектами — скорее всего, вы пытаетесь сделать атомиками то, что проще и безопаснее выразить через блокировку.
6. Типичные ошибки при работе с load/store/exchange/fetch_*
Ошибка №1: инкремент через store(load()+1) и потеря обновлений.
Это одна из самых частых логических ловушек: оба вызова атомарные, но вместе они не образуют атомарный «инкремент». Между load() и store() другой поток может поменять значение, и вы затрёте его работу. Если нужен счётчик — используйте fetch_add или CAS-цикл.
Ошибка №2: забыть инициализировать expected перед compare_exchange_*.
CAS сравнивает текущее значение с тем, что вы ему передали в expected. Если expected не задан, вы сравниваете атомик с мусором, а потом удивляетесь, что «никогда не срабатывает». expected всегда должен начинаться с осмысленного значения: «что я ожидаю увидеть».
Ошибка №3: не понимать, что expected меняется при провале CAS.
Многие новички пишут CAS, а потом в else ветке считают, что expected всё ещё «старое ожидаемое». На самом деле при неуспехе expected перезаписывается текущим значением атомика. Это не баг, а фича: именно так удобно строить CAS-циклы.
Ошибка №4: использовать compare_exchange_weak без цикла.
weak может провалиться «просто так», даже если значение совпало. Поэтому одиночный вызов compare_exchange_weak иногда создаёт крайне странные, редкие, «неповторяемые» баги. Если берёте weak, почти всегда делайте цикл. Если цикл не нужен и вы хотите проще — используйте strong.
Ошибка №5: делать тяжёлую работу внутри CAS-цикла.
CAS-цикл может повторяться много раз при конкуренции потоков. Если вы внутри цикла печатаете в консоль, лезете в контейнер, вызываете сложную функцию или, не дай бог, спите — вы можете получить катастрофическое падение производительности и очень труднообъяснимое поведение. В CAS-цикле желательно оставлять только короткую математику и сам compare_exchange.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ