JavaRush /Курсы /C++ SELF /Операции std::atomic...

Операции std::atomic: load/ store/ exchange/ fetch_*

C++ SELF
72 уровень , 1 лекция
Открыта

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. Для целых обычно доступны:

Операция Смысл Возвращает
fetch_add(v)
прибавить v старое значение
fetch_sub(v)
вычесть v старое значение
fetch_and(mask)
побитовое AND старое значение
fetch_or(mask)
побитовое OR старое значение
fetch_xor(mask)
побитовое 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.

1
Задача
C++ SELF, 72 уровень, 1 лекция
Недоступна
Атомарная капсула
Атомарная капсула
1
Задача
C++ SELF, 72 уровень, 1 лекция
Недоступна
Переключатель режима
Переключатель режима
1
Задача
C++ SELF, 72 уровень, 1 лекция
Недоступна
Очередь талонов
Очередь талонов
1
Задача
C++ SELF, 72 уровень, 1 лекция
Недоступна
Рекорд высоты
Рекорд высоты
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ