JavaRush /Курсы /C++ SELF /wait/notify на атомиках и std::atomic_flag

wait/notify на атомиках и std::atomic_flag

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

1. Зачем atomic::wait, если есть spin-wait

Если вы уже пробовали делать ожидание атомарного флага в цикле вида while (!ready) { } — поздравляю: вы написали spin-wait. Он реально работает… иногда… и при этом способен сжечь одно ядро CPU так, будто вы майните крипту на ноутбуке в библиотеке. Хочется научиться ждать событие так, чтобы поток спал, а не «нервно моргал глазом».

Представим самый типичный «учебный» spin-wait:

#include <atomic>
#include <thread>

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

    while (!ready.load()) {
        std::this_thread::yield(); // "уступаю", но всё равно крутимся
    }
}

Вроде бы мы даже добавили yield(), но по сути поток продолжает постоянно проверять флаг. Иногда это допустимо в очень низкоуровневых сценариях, но для обычного приложения это почти всегда перерасход ресурсов.

В C++20 у атомиков появился более цивилизованный путь: ждать изменения значения через wait() и будить ожидающих через notify_one()/notify_all(). Идея простая: «пока значение равно X — спи; когда оно станет не X — проснись».

Небольшая таблица для ориентира:

Что мы делаем Чем Как ощущается
Ждём в цикле spin-wait (while(load())) «Процессор греется, вентилятор поёт»
Ждём сложное условие (несколько переменных) mutex + condition_variable «Надёжно, но требует дисциплины с mutex»
Ждём изменение одного атомарного значения atomic::wait + notify «Минимум кода, поток реально блокируется»

2. Как работает atomic::wait(expected) и notify_*

С wait() чаще всего путаются из‑за одной мелочи: он не ждёт «пока станет expected». Он ждёт «пока равно expected». То есть это буквально команда: «Если сейчас значение равно expected, то я ложусь спать до тех пор, пока оно не поменяется».

Минимальный пример, чтобы зафиксировать смысл:

#include <atomic>
#include <thread>

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

    state.wait(0);   // заснёт, пока state == 0
    // сюда попадём, когда state != 0 (или при ложном пробуждении)
}

Как выглядит стандартный «сигнал» со стороны другого потока: сначала меняем значение, потом будим.

#include <atomic>

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

    ready.store(true);
    ready.notify_one(); // или notify_all()
}

Здесь важна дисциплина: сначала меняем состояние, потом notify. notify_* — это не «магически передать значение», а скорее «пинок: проснись и перепроверь условие». Очень похоже на философию condition_variable, и это не случайность.

Небольшая схема «как это должно идти»:

sequenceDiagram
    participant W as Worker
    participant A as atomic<T>
    participant M as Main

    M->>A: wait(expected)
    Note over M: спит, пока A == expected
    W->>A: store(new_value)
    W->>A: notify_one/notify_all
    M-->>A: проснулся и перепроверил значение

3. Надёжный шаблон: wait() почти всегда живёт внутри while

wait() делает ожидание удобным, но он не отменяет старое правило: любое ожидание должно перепроверять условие. Причина та же, что и у condition_variable: возможны «ложные пробуждения», гонки по времени, и даже ситуация, когда значение уже поменялось до того, как вы успели вызвать wait(). Если вы сделаете один wait() без проверки — код может быть хрупким.

Надёжный шаблон выглядит так: сначала смотрим условие через load(), и если оно ещё «не выполнено», зовём wait() с ожидаемым значением.

#include <atomic>

void wait_until_ready(std::atomic<bool>& ready) {
    while (!ready.load()) {
        ready.wait(false); // спим, пока ready == false
    }
}

Обратите внимание на «двойной шаг»: load() + wait(false). Он кажется избыточным, но это как ремень безопасности. Если ready уже true, wait(false) просто не будет ждать (потому что значение не равно false) — и мы быстро выйдем.

Почти то же самое можно делать со «статусом» в std::atomic<int>:

#include <atomic>

void wait_until_done(std::atomic<int>& state) {
    while (state.load() != 2) {
        state.wait(0); // ждём, пока state перестанет быть 0 (или проснёмся зря)
    }
}

Да, здесь пример нарочно немного «кривой»: мы ждём конкретно ухода с 0, а потом всё равно проверяем != 2. Это показывает реальную жизнь: wait() — не «ждать именно нужное», а «не тратить CPU, пока значение не меняется».

4. Мини‑пример: джоб‑раннер и готовность через wait/notify

Чтобы было не теорией в вакууме, продолжим учебное «мини‑приложение»: один поток считает результат, второй ждёт, когда результат будет готов, и печатает его. Это близко к реальным сценариям: «фоновая работа → сигнал готовности → UI/главный поток продолжает».

Сначала заведём общую структуру. Важно: результат sum — обычное поле, не атомик. Значит, нам нужен протокол публикации через атомарный флаг ready.

#include <atomic>
#include <vector>

struct Job {
    std::vector<int> data;
    long long sum = 0;           // НЕ атомик
    std::atomic<bool> ready{false};
};

Теперь поток‑работник: он считает сумму, записывает sum, после чего поднимает флаг и делает notify_one(). Здесь очень уместно помнить про acquire/release: «сначала данные → потом флаг».

#include <atomic>
#include <numeric>
#include <thread>

void run_job(Job& job) {
    job.sum = std::accumulate(job.data.begin(), job.data.end(), 0LL);
    job.ready.store(true, std::memory_order_release);
    job.ready.notify_one();
}

Главный поток ждёт. Мы не крутимся в while с постоянным yield, а блокируемся через wait(false).

#include <atomic>
#include <iostream>

void print_result(Job& job) {
    while (!job.ready.load(std::memory_order_acquire)) {
        job.ready.wait(false);
    }
    std::cout << job.sum << '\n'; // например: 15
}

И наконец, склеим всё в main() — совсем коротко:

#include <thread>

int main() {
    Job job{{1, 2, 3, 4, 5}};

    std::jthread worker([&] { run_job(job); });
    print_result(job);
}

Здесь мы получили аккуратную композицию из трёх идей. Результат считается в фоне, публикация делается через release-store флага, ожидание не жжёт CPU, а чтение результата после acquire-load корректно «подхватывает» записанные данные.

5. atomic<int>::wait: ожидание изменения состояния и прогресса

Иногда булевого флага мало: хочется видеть, в каком состоянии находится работа. Для этого удобно держать std::atomic<int> или std::atomic<enum> (но enum чуть сложнее для новичка), и ждать, когда состояние изменится.

Представим простой контракт состояний:

Значение state Смысл
0 ещё не стартовали
1 работаем
2 готово

Работник переводит состояние по шагам и будит ожидающих:

#include <atomic>

void set_state(std::atomic<int>& state, int v) {
    state.store(v);
    state.notify_all();
}

Ожидатель может ждать «пока не уйдём с 0», а затем «пока не станет 2». Да, это две фазы — но зато читаемо.

#include <atomic>

void wait_started_then_done(std::atomic<int>& state) {
    while (state.load() == 0) state.wait(0);
    while (state.load() != 2) state.wait(1);
}

На практике второй wait(1) не гарантирует, что значение обязательно именно 1 во время ожидания (оно может быстро перескочить). Поэтому тут снова спасает while: мы перепроверяем после каждого пробуждения.

Похожая идея работает и для прогресса. Например, у нас есть std::atomic<int> progress{0}; (в процентах). Главный поток хочет печатать прогресс только когда он меняется, но не крутиться.

#include <atomic>
#include <iostream>

void print_progress(std::atomic<int>& progress) {
    int last = progress.load();
    while (last < 100) {
        progress.wait(last);
        last = progress.load();
        std::cout << "progress=" << last << "%\n"; // progress=30%
    }
}

Это уже похоже на взрослую «подписку на изменение значения»: ждать именно «изменится относительно прошлого» — и печатать новое.

6. std::atomic_flag и границы применения wait/notify

std::atomic_flag: минималистичный флажок и сценарий «сделать один раз»

std::atomic_flag — это самый простой атомарный флаг в стандартной библиотеке. Он не похож на «bool с чтением и записью» в привычном смысле: его главный метод — test_and_set(), который всегда устанавливает флаг и возвращает «что было до этого». Это очень удобно, когда нужно реализовать поведение «первый сделал — молодец, остальные молча прошли мимо».

У atomic_flag есть долгоживущая репутация «минимального и часто lock-free», а в новых стандартах он тоже получил более богатый интерфейс синхронизации.

Классический do once:

#include <atomic>
#include <iostream>

int main() {
    std::atomic_flag once = ATOMIC_FLAG_INIT;

    if (!once.test_and_set()) {
        std::cout << "init only once\n"; // init only once
    }
}

Если вы вызовете этот фрагмент из двух потоков (например, оба пытаются «инициализировать лог» или «один раз показать предупреждение»), то ровно один поток увидит false и выполнит действие. Остальные получат true и пропустят.

Важно понять психологически: test_and_set() — это не «посмотреть флаг». Это «посмотреть и сразу поставить». Если вам нужно просто проверять «а флаг уже поднят?» — у atomic_flag это не самый удобный инструмент (у std::atomic<bool> привычнее API).

Можно показать это на чуть более «живом» кусочке кода: например, мы хотим один раз напечатать подсказку «работа запущена», даже если несколько потоков попытаются это сделать.

#include <atomic>
#include <iostream>

void print_started_once(std::atomic_flag& started_once) {
    if (!started_once.test_and_set()) {
        std::cout << "job started\n"; // job started
    }
}

Если вам нужно «разрешить снова», используйте clear():

#include <atomic>

int main() {
    std::atomic_flag f = ATOMIC_FLAG_INIT;

    f.test_and_set(); // теперь true
    f.clear();        // теперь снова false
}

Когда atomic::wait/notify не заменяет condition_variable

Очень хочется после знакомства с wait/notify начать заменять ими всё подряд, потому что «кода меньше, значит лучше». Но тут нужно удержать себя за рукав. atomic::wait идеален, когда условие ожидания описывается одной переменной: «флаг готовности», «статус», «версия», «прогресс».

Как только условие становится составным (например: «очередь задач не пуста ИЛИ пришёл stop‑сигнал»), вы быстро окажетесь в мире, где одно атомарное значение уже не выражает всю логику. Да, можно кодировать это в одном int битами, можно делать сложные протоколы — но это уже путь в «сам себе стандартный комитет».

В таких местах mutex + condition_variable остаются проще: вы защищаете несколько полей одним мьютексом, а wait(lock, predicate) делает то самое «правильное ожидание с перепроверкой» в одном шаблоне. atomic::wait — не «убийца condition_variable», а скорее «лёгкий инструмент для лёгких условий».

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

Ошибка №1: путать смысл wait(expected) и ждать «пока станет expected».
Если вы мысленно читаете wait(false) как «ждать пока будет false», вы пишете код, который зависает ровно в тот момент, когда вы этого меньше всего ожидаете. Правильное чтение: wait(false) означает «спать, пока значение равно false».

Ошибка №2: делать notify_*() до изменения значения.
Если вы сначала «пнёте» ожидающих, а потом поменяете значение, они могут проснуться, посмотреть на старое значение, снова уснуть — и всё, сигнал потерян. Правило простое и почти скучное: сначала store/exchange/fetch_* (то есть меняем состояние), затем notify_one/notify_all.

Ошибка №3: писать один wait() без цикла перепроверки.
Даже если вам кажется, что «ложных пробуждений не будет», жизнь найдёт способ вас удивить. Надёжный паттерн — while (условие_ещё_не_выполнено) atomic.wait(expected); — и только после этого вы действуете.

Ошибка №4: пытаться atomic::wait использовать для сложных условий (очередь, несколько полей).
Обычно это заканчивается тем, что вы либо вводите дополнительную «сигнальную» переменную, либо начинаете городить хитрые инварианты, а через неделю сами не понимаете, почему оно иногда зависает. Если условие описывается не одним значением — берите mutex + condition_variable.

Ошибка №5: воспринимать std::atomic_flag как «обычный bool» и пытаться «просто прочитать».
atomic_flag::test_and_set() всегда меняет состояние (ставит true). Если вы вызываете его «просто проверить» — вы сами поднимаете флаг и меняете поведение программы. Для «обычного» флага с чтением/записью чаще подходит std::atomic<bool>.

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