JavaRush /Курсы /C++ SELF /std::atomic: флаги и счётчики + выбор stop_token

std::atomic: флаги и счётчики + выбор stop_token

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

1. От гонок данных к std::atomic

Если вы пишете однопоточный код, то переменная int counter — просто число, а bool stop — просто флажок. Но как только рядом появляется второй поток, эти «просто переменные» превращаются в источник загадок уровня «почему оно зависает только по пятницам после обеда?». Суть в том, что два потока могут одновременно читать/писать одну и ту же память, и без дисциплины это приводит к гонке данных (data race), а она в C++ считается Undefined Behavior: то есть «может работать, может не работать, может делать вид, что работает».

Представьте типичный наивный стоп-флаг:

#include <chrono>
#include <iostream>
#include <thread>

int main() {
    bool stop = false;

    std::thread worker([&] {
        while (!stop) { /* work */ }
    });

    std::this_thread::sleep_for(std::chrono::milliseconds{10});
    stop = true;

    worker.join();
    std::cout << "done\n"; // done
}

На вид всё логично: главный поток ставит stop = true, рабочий поток должен это увидеть и завершиться. Но в реальности компилятор и процессор имеют право переупорядочивать и оптимизировать операции так, что рабочий поток может «не увидеть» обновление вовремя (или вообще никогда, если оптимизация решит, что stop не меняется). Мы не будем сегодня уходить в детали модели памяти (это отдельная тема), нам важен практический вывод: если переменная используется из нескольких потоков — она должна быть защищена либо мьютексом, либо атомиком, либо другим корректным механизмом.

std::atomic<T> как контракт для одной переменной

Когда в проекте появляется std::atomic<T>, это не «ускоритель», не «магия» и не «мьютекс для бедных». Это способ сказать компилятору и читателю кода: «вот эта конкретная переменная — общая между потоками, доступ к ней должен быть потокобезопасным». И в этом месте код становится честнее: мы явно выражаем намерение, а не надеемся, что “и так сойдёт”.

Полезная (и немного грустная) мысль: std::atomic хорошо работает, пока у вас ровно одна независимая ячейка состояния. Например, “остановиться/не останавливаться”, “сколько задач выполнено”, “готов ли результат”. Как только у вас появляется инвариант на нескольких переменных (например, size должен соответствовать реальному размеру очереди, плюс нужно безопасно перемещать элементы), атомик уже не спасёт. Там нужен mutex (или более сложные структуры, которые мы сегодня не трогаем).

Минимальный синтаксис выглядит так:

#include <atomic>

int main() {
    std::atomic<bool> ready{false};
    std::atomic<int>  progress{0};
}

Почти всегда в учебных (и многих прикладных) примерах мы используем атомики с умолчальным порядком памяти (по сути: “сделай безопасно и предсказуемо”). Это не самый быстрый вариант, но самый понятный — а нам сейчас важнее, чтобы код не превратился в квантовую механику на дедлайне.

2. Минимальные операции атомика

Когда вы впервые видите std::atomic<int>, хочется обращаться к нему как к обычному int: писать x++, if (x), x = 5. Часто это будет работать, но в учебных целях полезно начать с явных операций, потому что они сразу показывают намерение: чтение, запись, атомарное увеличение, атомарная замена.

Ниже — «словарик на сегодня». Это прям тот набор, с которым можно прожить большую часть прикладных задач “флаг/счётчик”.

Операция Что делает Что возвращает Типичный смысл
a.load()
атомарно читает значение
T
«проверить флаг / прочитать счётчик»
a.store(v)
атомарно записывает значение
void
«установить флаг / записать новое значение»
a.fetch_add(k)
атомарно прибавляет k старое значение T «увеличить счётчик прогресса»
a.exchange(v)
атомарно заменяет на v старое значение T «сделать действие ровно один раз»

load() и store()

Начнём мягко: чтение и запись.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<bool> ready{false};

    ready.store(true);
    bool v = ready.load();

    std::cout << std::boolalpha << v << '\n'; // true
}

Здесь важен не результат (он скучный), а контракт: оба потока могут делать load/store без гонки данных.

fetch_add() для счётчика

Теперь чуть полезнее — счётчик.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<int> counter{0};

    counter.fetch_add(1);
    counter.fetch_add(1);

    std::cout << counter.load() << '\n'; // 2
}

Главное отличие от «обычного» counter = counter + 1 в том, что fetch_addодна атомарная операция, а не «прочитал → прибавил → записал» тремя шагами, которые два потока легко перемешают.

exchange() как «кто первый — тот и молодец»

Иногда нужно, чтобы какой-то кусок кода выполнился ровно один раз (например, “инициализацию делаем один раз”, “сообщение печатаем один раз”). Для этого exchange очень удобен.

#include <atomic>
#include <iostream>

int main() {
    std::atomic<bool> started{false};

    if (!started.exchange(true)) {
        std::cout << "first\n";   // first
    } else {
        std::cout << "already\n";
    }
}

exchange(true) атомарно «ставит флажок» и возвращает предыдущее значение. Если предыдущее было false, вы первый. Если true, кто-то успел раньше.

3. std::atomic<bool>: стоп‑флаг и готовность

atomic<bool> — это такой минимальный “светофор”: красный/зелёный. Его очень любят, потому что он легко читается и выглядит дружелюбнее, чем mutex + condition_variable. Но тут важно не перепутать: атомик — это про корректный доступ, а не про ожидание.

То есть atomic<bool> отлично отвечает на вопрос “можно ли уже?” или “пора ли остановиться?”, но сам по себе он не умеет “усыпить поток до события”. Поэтому если вы начнёте делать while (!flag.load()) {} — вы получите активное ожидание (busy-wait), то есть поток будет есть CPU как голодный студент пиццу на халяве.

Простой стоп‑флаг

Вот базовая схема: один поток работает, другой просит остановиться.

#include <atomic>
#include <chrono>
#include <thread>

int main() {
    std::atomic<bool> stop{false};

    std::jthread worker([&] {
        while (!stop.load()) {
            std::this_thread::sleep_for(std::chrono::milliseconds{10});
        }
    });

    std::this_thread::sleep_for(std::chrono::milliseconds{50});
    stop.store(true);
}

Обратите внимание на sleep_for: он здесь не для красоты, а чтобы цикл не превратился в «гриль для процессора». Это рабочий учебный паттерн, но в реальных системах ожидание лучше делать либо через condition_variable, либо через stop_token в jthread, либо через более продвинутые механизмы.

Готовность результата

Другой классический сценарий: один поток “готовит” результат, другой ждёт, когда можно продолжать.

#include <atomic>
#include <chrono>
#include <thread>

int main() {
    std::atomic<bool> ready{false};

    std::jthread producer([&] {
        std::this_thread::sleep_for(std::chrono::milliseconds{30});
        ready.store(true);
    });

    while (!ready.load()) {
        std::this_thread::sleep_for(std::chrono::milliseconds{5});
    }
}

Это демонстрация идеи, а не идеальная архитектура ожидания. Если ожидание — важная часть логики, то condition_variable чаще будет лучше, потому что она умеет “спать до уведомления”, а не “просыпаться каждые 5 мс и спрашивать”.

4. std::atomic<int>: прогресс и счётчики

Если atomic<bool> — светофор, то atomic<int> — счётчик на турникете: сколько людей прошло. Это удобно, когда разные потоки независимо делают однотипную работу, и вам нужно просто посчитать события: “сколько задач обработано”, “сколько байт скачано”, “сколько ошибок”.

Многопоточное увеличение счётчика

Классическая демонстрация: несколько потоков увеличивают один счётчик, и в конце мы проверяем, что ничего не потерялось.

#include <atomic>
#include <thread>
#include <vector>
#include <iostream>

int main() {
    std::atomic<int> counter{0};

    {
        std::vector<std::jthread> threads;
        for (int i = 0; i < 4; ++i) {
            threads.emplace_back([&] {
                for (int n = 0; n < 1000; ++n) {
                    counter.fetch_add(1);
                }
            });
        }
    } // jthread сам дождётся завершения

    std::cout << counter.load() << '\n'; // 4000
}

Ключевой момент: мы защитили ровно одну переменную, и этого достаточно. Если бы вместо fetch_add стоял counter = counter + 1, результат легко мог бы быть меньше 4000, потому что операции “прочитал/прибавил/записал” вмешиваются друг в друга.

Почему атомик не заменяет мьютекс

Допустим, вы хотите одновременно обновлять две переменные: doneTasks и doneBytes, и у вас есть условие “если doneTasks вырос, doneBytes тоже должен вырасти”. Это уже инвариант на нескольких значениях. Атомиками по отдельности вы не гарантируете согласованность пары как единого состояния: другой поток может увидеть “новое doneTasks, старое doneBytes”. Если это недопустимо — значит, вам нужен mutex вокруг общей “транзакции изменения”.

5. Очередь задач и прогресс: смешиваем mutex, cv и атомики

До этого места мы работали с атомиками как с изолированными примерами. Теперь давайте сделаем то, ради чего вообще всё затевалось: смешаем правильные инструменты. У нас уже есть знакомая модель producer–consumer: очередь задач, рабочий поток, ожидание через condition_variable, корректное завершение. Атомики добавим для того, для чего они идеальны: прогресс и простые флаги, которые читаются без блокировок.

Представим, что наше учебное консольное приложение — это “мини‑обработчик задач”: главный поток складывает числа в очередь, рабочий поток “обрабатывает” их (например, считает квадрат), а главный поток иногда хочет показать прогресс: сколько задач уже сделано.

Заготовка общего состояния

#include <atomic>
#include <condition_variable>
#include <mutex>
#include <queue>

struct SharedState {
    std::mutex m;
    std::condition_variable cv;
    std::queue<int> tasks;

    std::atomic<int> done{0};
    std::atomic<bool> stop{false};
};

Здесь очередь защищается мьютексом (потому что это сложное состояние), а done/stop — атомики, потому что это отдельные независимые переменные, которые хочется читать без блокировки.

Поток‑работник: ждём, берём, обрабатываем, увеличиваем счётчик

#include <chrono>
#include <thread>

void workerLoop(SharedState& s) {
    while (!s.stop.load()) {
        int x = 0;

        {   // критическая секция: только для очереди
            std::unique_lock lk{s.m};
            s.cv.wait(lk, [&] { return s.stop.load() || !s.tasks.empty(); });

            if (s.stop.load()) return;

            x = s.tasks.front();
            s.tasks.pop();
        }

        std::this_thread::sleep_for(std::chrono::milliseconds{5}); // "работа"
        s.done.fetch_add(1);
    }
}

Обратите внимание на важную композицию: condition_variable ждёт по predicate, который учитывает и “очередь не пуста”, и “пришла остановка”. Мы не делаем “проверку stop где-то отдельно”, чтобы не было редких зависаний при завершении.

Главный поток: добавляем задачи и наблюдаем прогресс

#include <iostream>
#include <thread>

int main() {
    SharedState s;

    std::jthread w([&] { workerLoop(s); });

    {   // кладём 20 задач
        std::lock_guard lg{s.m};
        for (int i = 0; i < 20; ++i) s.tasks.push(i);
    }
    s.cv.notify_one();

    while (s.done.load() < 20) {
        std::cout << "done = " << s.done.load() << '\n'; // например: done = 7
        std::this_thread::sleep_for(std::chrono::milliseconds{20});
    }

    s.stop.store(true);
    s.cv.notify_one();
}

Этот кусочек демонстрирует идею “прогресс можно читать без мьютекса”. Очередь мы всё равно трогаем под мьютексом, но done читается просто через load().

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

6. Как выбрать: stop_token, condition_variable или atomic<bool>

Когда в голове появляются сразу три механизма “про остановку/сигнал”, легко начать путаться и выбирать по принципу “что короче пишется”. Но в многопоточности лучше выбирать по роли, иначе очень быстро получается либо прожорливый busy-wait, либо вечное «почему оно не просыпается».

Ниже — практичная карта выбора. Её стоит читать не как «три способа сделать одно и то же», а как «три инструмента для разных задач».

Инструмент Лучшее применение Что он даёт принципиально Типичный анти‑кейс
std::stop_token (обычно с std::jthread) Кооперативная остановка долгоживущей работы Стандартизованный “протокол просьбы остановиться”, хорошо читается в API Пытаться использовать как “очередь событий” или “сигнал готовности данных”
mutex + condition_variable Ожидание “пока не изменится сложное состояние” (очередь задач, несколько полей, инварианты) Блокирующее ожидание без жора CPU + защита сложного состояния Заменять на “while(!flag) sleep” там, где есть реальная очередь/состояние
std::atomic<bool> Очень простой потокобезопасный флаг “да/нет” Дешёвое чтение/запись одной переменной без мьютекса Делать на нём ожидание события в tight loop (busy‑wait) или “защищать контейнер”

Если сформулировать почти по-человечески: stop_token — это про “вежливо попросить остановиться”; condition_variable — про “спать, пока не появится работа/событие”; atomic<bool> — про “хранить общий флажок корректно”, но не обязательно про ожидание.

И ещё один важный критерий: если вам надо защищать несколько связанных значений или структуру данных (очередь, vector, несколько полей “всё сразу”) — выбирайте mutex. Атомики хороши именно тогда, когда переменная сама по себе и вам не нужна сложная согласованность.

7. Типичные ошибки при работе с std::atomic

Ошибка №1: смешивать атомарный и неатомарный доступ к одной и той же переменной.
Очень распространённая ловушка: “ну тут я читаю flag.load(), а тут я просто напишу flag = true, оно же то же самое”. На практике это ломает контракт и снова возвращает вас к гонке данных. Если переменная стала atomicвсе доступы к ней должны идти через атомарные операции (или хотя бы через корректные перегруженные операторы, но лучше привыкать к load/store).

Ошибка №2: пытаться «атомиком защитить контейнер».
Иногда хочется сделать std::atomic<int> size и думать, что очередь/вектор теперь “почти потокобезопасны”. Неа. Контейнер — это много памяти, много полей и инварианты. Атомик защищает одну переменную, а не сложное состояние. Для контейнеров в наших текущих моделях остаётся mutex, и это нормально: это честный и понятный инструмент.

Ошибка №3: превращать atomic<bool> в активное ожидание (busy‑wait).
Код вида while (!ready.load()) {} выглядит минималистично, но на практике это нагружает CPU и в реальных программах превращается в “почему мой ноутбук греется, когда я просто жду?”. Если вы реально ждёте события — чаще всего нужен condition_variable, чтобы поток спал и просыпался по уведомлению, а не “проверял каждые 0 наносекунд”.

Ошибка №4: ожидать, что атомик “сделает программу потокобезопасной целиком”.
Атомик — это не святая вода, которую можно побрызгать на проект. Он решает конкретную задачу: корректные операции над конкретной переменной. Если рядом есть ещё разделяемые данные (очередь, строка, вектор, несколько полей модели), они требуют своей синхронизации. Когда это принимаешь, код становится спокойнее и предсказуемее.

Ошибка №5: забыть, что атомики обычно не копируются.
std::atomic<T> — особый тип: его копирование часто запрещено (или ограничено), и это намеренно. Поэтому если вы пытаетесь “передать атомик по значению в функцию” или “положить в структуру и копировать структуру как раньше”, компилятор может справедливо возмутиться. В таких местах обычно выбирают передачу по ссылке (std::atomic<int>&) или хранят атомик в одном месте и раздают ссылки/указатели на него по контракту.

1
Задача
C++ SELF, 71 уровень, 4 лекция
Недоступна
Атомарный тумблер
Атомарный тумблер
1
Задача
C++ SELF, 71 уровень, 4 лекция
Недоступна
Атомарный одометр
Атомарный одометр
1
Задача
C++ SELF, 71 уровень, 4 лекция
Недоступна
Командный инкремент
Командный инкремент
1
Задача
C++ SELF, 71 уровень, 4 лекция
Недоступна
Очередь с прогрессом
Очередь с прогрессом
1
Опрос
async/future/promise, 71 уровень, 4 лекция
Недоступен
async/future/promise
async/future/promise
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ