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>.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ