CodeGym /Courses /C++ SELF /wait/notify on atomics and std::atomic_flag

wait/notify on atomics and std::atomic_flag

C++ SELF
Level 72, Lesson 4
Available

1. Why atomic::wait if there is spin-wait

If you've tried waiting on an atomic flag with a loop like while (!ready) { } — congratulations: you wrote a spin-wait. It actually works… sometimes… and at the same time it can burn one CPU core as if you were mining crypto on a laptop in a library. You want to learn to wait for an event so that the thread sleeps, not "twitches its eye nervously".

Imagine the most typical "tutorial" spin-wait:

#include <atomic>
#include <thread>

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

    while (!ready.load()) {
        std::this_thread::yield(); // "yielding", but still spinning
    }
}

It looks like we even added yield(), but essentially the thread keeps repeatedly checking the flag. Sometimes this is acceptable in very low-level scenarios, but for a normal application it's almost always wasteful.

In C++20 atomics got a more civilized way: wait for a value change via wait() and wake waiters via notify_one()/notify_all(). The idea is simple: "while the value equals X — sleep; when it becomes not X — wake up".

A small table for orientation:

What we do With How it feels
Wait in a loop spin-wait (while(load())) “CPU heats up, the fan sings”
Wait for a complex condition (multiple variables) mutex + condition_variable “Reliable, but requires discipline with the mutex”
Wait for a change of a single atomic value atomic::wait + notify “Minimal code, the thread really blocks”

2. How atomic::wait(expected) and notify_* work

People often get wait() wrong because of one subtlety: it does not wait “until it becomes expected”. It waits “while it is equal to expected”. So it is literally the command: "If the value is equal to expected now, I go to sleep until it changes".

A minimal example to fix the meaning:

#include <atomic>
#include <thread>

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

    state.wait(0);   // will sleep while state == 0
    // execution continues here when state != 0 (or on spurious wakeup)
}

How a standard "signal" from another thread looks: first change the value, then wake.

#include <atomic>

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

    ready.store(true);
    ready.notify_one(); // or notify_all()
}

Discipline matters here: change the state first, then notify. notify_* is not "magically transfer the value", but rather a "kick: wake up and recheck the condition". This is very similar to the philosophy of condition_variable, and that's not accidental.

A small sequence showing “how it should go”:

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

    M->>A: wait(expected)
    Note over M: sleeps while A == expected
    W->>A: store(new_value)
    W->>A: notify_one/notify_all
    M-->>A: woke up and rechecked the value

3. Reliable pattern: wait() almost always lives inside a while

wait() makes waiting convenient, but it does not cancel the old rule: any wait must recheck the condition. The reason is the same as for condition_variable: spurious wakeups, timing races, and even the case when the value changed before you had a chance to call wait(). If you call a single wait() without checking — the code can be fragile.

A reliable pattern looks like this: first check the condition with load(), and if it is still "not satisfied", call wait() with the expected value.

#include <atomic>

void wait_until_ready(std::atomic<bool>& ready) {
    while (!ready.load()) {
        ready.wait(false); // sleep while ready == false
    }
}

Notice the "two-step": load() + wait(false). It feels redundant, but it's like a seat belt. If ready is already true, wait(false) will simply not wait (because the value is not false) — and we exit quickly.

You can do almost the same with a "status" in std::atomic<int>:

#include <atomic>

void wait_until_done(std::atomic<int>& state) {
    while (state.load() != 2) {
        state.wait(0); // wait while state remains 0 (or wake spuriously)
    }
}

Yes, this example is intentionally a bit "crooked": we wait specifically for leaving 0, and then still check != 2. This shows real life: wait() is not "wait exactly for the desired value", but "don't waste CPU while the value doesn't change".

4. Minimal example: job runner and readiness via wait/notify

To avoid theory in vacuum, let's continue with a small "mini-application": one thread computes a result, the other waits for the result to be ready and prints it. This is close to real scenarios: "background work → ready signal → UI/main thread continues".

First we create the shared structure. Important: the result sum is a regular field, not atomic. So we need a publication protocol via the atomic flag ready.

#include <atomic>
#include <vector>

struct Job {
    std::vector<int> data;
    long long sum = 0;           // NOT atomic
    std::atomic<bool> ready{false};
};

Now the worker thread: it computes the sum, writes sum, then raises the flag and calls notify_one(). Remember acquire/release: "data first → then the flag".

#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();
}

The main thread waits. We don't spin in a while with constant yield, but block via 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'; // e.g.: 15
}

And finally, glue everything in main() — very briefly:

#include <thread>

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

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

Here we have a neat composition of three ideas. The result is computed in the background, publication is done via a release-store of the flag, waiting does not burn CPU, and reading the result after an acquire-load correctly "sees" the written data.

5. atomic<int>::wait: waiting for state changes and progress

Sometimes a boolean flag is not enough: you want to know what state the work is in. For that it's convenient to keep std::atomic<int> or std::atomic<enum> (though enum is slightly harder for newcomers), and wait until the state changes.

Imagine a simple contract of states:

Value state Meaning
0 not started yet
1 working
2 done

The worker advances the state step by step and wakes waiters:

#include <atomic>

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

A waiter can wait "until we leave 0", and then "until it becomes 2". Yes, that's two phases — but it's readable.

#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);
}

In practice the second wait(1) does not guarantee the value is exactly 1 while waiting (it may quickly jump). So the while saves us again: we recheck after each wakeup.

A similar idea works for progress reporting. For example, we have std::atomic<int> progress{0}; (in percent). The main thread wants to print progress only when it changes, but not spin.

#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%
    }
}

This already looks like a grown-up "subscription to value changes": wait exactly for "change relative to the previous" — and then print the new value.

6. std::atomic_flag and boundaries of wait/notify usage

std::atomic_flag: the minimalist flag and the “do once” scenario

std::atomic_flag is the simplest atomic flag in the standard library. It's not quite like a "bool with read/write" in the usual sense: its main method is test_and_set(), which always sets the flag and returns "what was before". This is handy when you need "first wins — others silently skip".

atomic_flag has a longstanding reputation as "minimal and often lock-free", and in newer standards it also gained a richer synchronization interface.

Classic 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
    }
}

If you run this snippet from two threads (for example, both trying to "initialize a log" or "show a warning once"), exactly one thread will observe false and perform the action. Others will get true and skip.

It's important to understand conceptually: test_and_set() is not "just look at the flag". It's "look and set it immediately". If you need simply to check "is the flag already raised?" — atomic_flag is not the most convenient tool (for a "regular" flag with read/write the API of std::atomic<bool> is more natural).

We can show this with a slightly more "live" snippet: e.g., we want to print the hint "job started" once, even if multiple threads try to do it.

#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
    }
}

If you need to "allow again", use clear():

#include <atomic>

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

    f.test_and_set(); // now true
    f.clear();        // now false again
}

When atomic::wait/notify does not replace condition_variable

After discovering wait/notify it's tempting to try to replace everything with it because "less code is better". But you should resist. atomic::wait is ideal when the wait condition is described by a single variable: "ready flag", "status", "version", "progress".

Once the condition becomes composite (for example: "queue not empty OR stop signal arrived"), you'll quickly enter a world where one atomic value no longer expresses all the logic. Yes, you can encode this in a single int with bits, you can design complex protocols — but that's a path to "DIY standard committee".

In such places mutex + condition_variable remain simpler: you protect multiple fields with one mutex, and wait(lock, predicate) does the "proper wait with recheck" in one pattern. atomic::wait is not a "killer of condition_variable", but rather a "lightweight tool for light-weight conditions".

7. Typical mistakes when working with atomic::wait/notify and std::atomic_flag

Mistake #1: confusing the meaning of wait(expected) and waiting "until it becomes expected". If you mentally read wait(false) as "wait until it's false", you write code that deadlocks exactly when you least expect it. Correct reading: wait(false) means "sleep while the value equals false".

Mistake #2: calling notify_*() before changing the value.
If you "kick" waiters first and then change the value, they may wake, see the old value, go back to sleep — and the signal is lost. Simple rule: first store/exchange/fetch_* (i.e., change state), then notify_one/notify_all.

Mistake #3: calling one wait() without a recheck loop.
Even if you think "spurious wakeups won't happen", life will surprise you. A reliable pattern is while (condition_not_met) atomic.wait(expected); — and only after that act.

Mistake #4: trying to use atomic::wait for complex conditions (queue, multiple fields).
Usually this ends up with you adding an extra "signal" variable or inventing tricky invariants, and in a week you yourself won't understand why it sometimes deadlocks. If the condition is not expressible by a single value — use mutex + condition_variable.

Mistake #5: treating std::atomic_flag as a "regular bool" and trying to "just read" it.
atomic_flag::test_and_set() always changes the state (sets true). If you call it to "just check", you are the one raising the flag and changing program behavior. For a "regular" flag with read/write semantics, std::atomic<bool> is often more appropriate.

1
Task
C++ SELF, level 72, lesson 4
Locked
Readiness signal
Readiness signal
1
Task
C++ SELF, level 72, lesson 4
Locked
Two stages
Two stages
1
Task
C++ SELF, level 72, lesson 4
Locked
Progress with confirmation
Progress with confirmation
1
Task
C++ SELF, level 72, lesson 4
Locked
Simultaneous start
Simultaneous start
1
Survey/quiz
Atomics Deep Dive, level 72, lesson 4
Unavailable
Atomics Deep Dive
Atomics Deep Dive
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION