Sources this series builds on: Chili's (ChiliTomatoNoodle) multithreading
playlist,
CMU 15-213 (Computer Systems: A Programmer's Perspective), various CppCon talks,
and LLMs for restructuring and sanity-checking. Everything here is my own code,
compiled and run — not just transcribed.
Recap
Part 1 raced an int and got a wrong number — 66666666 instead of
266666664, a lost-update bug you could point at and explain with three
lines of assembly. Wrong, but at least it looked like arithmetic gone
wrong. This article races something simpler — a bool — and the result
isn't a wrong number at all. It's the compiler quietly throwing your loop
away.
The setup: a flag, and a thread that waits on it
The pattern: one thread does some slow work and then flips a flag. Another
thread just spins, checking the flag, waiting for it to become true.
struct nonAtomic { bool ready = false; };
struct atomic { std::atomic<bool> ready{ false }; };
template<typename T>
void runTest(const char* label) {
T data;
std::thread t1([&data]() {
std::this_thread::sleep_for(std::chrono::milliseconds(7000));
data.ready = true;
});
while (!data.ready) { }
std::cout << label << " - Finished waiting for ready flag.\n";
t1.join();
}
t1 sleeps 7 seconds, then sets ready = true. The main thread sits in
while (!data.ready) { }, doing nothing but re-checking, until it sees
that flip. Two versions of the same test — one with a plain bool, one
with std::atomic<bool> — run back to back:
void run() {
std::thread t1(&CompilerHazard::runTest<nonAtomic>, this, "nonAtomic");
std::thread t2(&CompilerHazard::runTest<atomic>, this, "atomic");
t1.join();
t2.join();
}
Nothing here looks dangerous. There's no +=, no lost update possible — a
bool can only be true or false, there's no "partial" value to race
over the way there was with cnt. Reasonable to assume this one's fine.
The problem: the compiler doesn't just get this wrong — it gets it wrong differently at every optimization level
Compile and run this exact code at three optimization levels and you get
three different stories:
At -O0 (no optimization): both versions work. Each one waits roughly
7 seconds, then prints its "finished waiting" line. Boring, correct, exactly
what you'd expect from reading the source.
At -O1: the atomic version still works correctly. The nonAtomic
version hangs forever. Not "takes longer" — never returns. t1 finishes
its sleep, sets ready = true, and the main thread's loop never notices.
At -O2: the atomic version is still correct, still ~7 seconds. The
nonAtomic version does something even stranger than hanging — it finishes
instantly, without ever actually waiting for t1's 7-second sleep at
all.
Same source code. Same hardware. Only the optimization flag changed, and
the plain-bool version gave three different, non-equivalent results
across three builds — one correct, one an infinite hang, one a nonsensical
instant "finish" that skipped the wait entirely.
Why: the compiler is allowed to assume this bug doesn't exist
while (!data.ready) { } re-checks ready on every loop iteration, hoping
to notice when another thread changes it. That's the intent.
But ready is a plain bool. One thread writes it, another thread reads
it, and nothing synchronizes the two. That's the exact definition of a data
race from Part 1 — and in C++, a data race is undefined behavior for the
entire program, not just that one variable.
Here's the part that trips people up: "undefined behavior" doesn't mean
"the compiler gives up." It means the compiler is legally allowed to
assume this situation never happens in a correct program. As far as the
optimizer can see, nothing in this function ever modifies ready once the
loop starts — so it treats ready as if it were constant for the rest of
the loop's life.
That one assumption plays out differently depending on how hard the
compiler is optimizing:
At -O1: the compiler reads ready once, stores it in a CPU register,
and just keeps re-checking that register forever — it never goes back out
to memory to look for t1's update. while (!data.ready) { } quietly
becomes while (true) { }. The check isn't wrong; it's just checking a
value that can never change.
At -O2: the compiler goes a step further. If no other thread is
allowed to touch ready, then the branch where this loop waits forever
for an external change is impossible — and impossible code is free to be
deleted. So it deletes the wait entirely and falls straight through, as if
the condition were already true.
Neither of these is a compiler bug. Both are legal optimizations, built on
a promise your code made by not synchronizing ready — and then broke.
std::atomic actually does two different jobs — don't blur them together
Here's where it's easy to get turned around: Part 1 fixed a race on cnt++.
This article is about a race on ready = true. Both get called "a data
race," both get fixed by std::atomic, and it's tempting to assume
std::atomic is one trick doing the same thing both times. It isn't. It's
two different guarantees, and each bug only needed one of them.
Job 1 — Atomicity: make a multi-step operation indivisible.
This is Part 1's problem. cnt += x is three instructions — read, modify,
write — and another thread can slip in between them. std::atomic<int>
fixes this by compiling the whole read-modify-write into one
hardware-locked instruction that cannot be interrupted:
; plain int: cnt += x --> three separate, interruptible steps
mov eax, [cnt]
add eax, edx
mov [cnt], eax
; std::atomic<int>: cnt.fetch_add(x) --> one indivisible step
lock xadd [cnt], eax
The lock prefix tells the CPU: no other core may touch this memory
location until this single instruction finishes. There's no longer a
"between the read and the write" for another thread to land in, because
there's no longer a between at all.
Job 2 — Visibility: make a completed write reachable by other cores, in
order.
This is this article's problem. ready = true is already one
instruction — there's nothing to make indivisible, it's not being
interrupted mid-write. The bug isn't timing, it's that the compiler cached
a stale read, and separately, a real write can sit in a core's private
cache without a guaranteed moment of becoming visible elsewhere.
std::atomic<bool> fixes this by stopping the compiler from caching the
read, and by inserting the memory-ordering guarantees that make the write's
completion visible to other cores in a well-defined way.
Same keyword, two different repairs, applied depending on what's actually
wrong with the operation you wrapped in std::atomic<>. A single store
never needed Job 1 — it was already indivisible. A multi-step
read-modify-write always needs Job 1, and usually gets Job 2 along with it
for free, since std::atomic provides both by default.
Back to ready — this article only ever needed Job 2
Since ready = true was already one instruction, there was nothing here
for atomicity to fix. The actual gap was purely visibility:
Each core has its own cache. A write can sit there for a while before it's
visible to any other core. Without a synchronization point, there's no
promise about when — or even in what order — one core's writes show up to
another core's reads. std::atomic<bool> closes that gap: not by making a
single store "more atomic" (it already was one instruction), but by forcing
that store to become visible to other cores in a defined, guaranteed way,
and by stopping the compiler from short-circuiting the check with a cached
register value.
The full "why" behind cross-core visibility — cache lines, per-core caches,
the coherency protocol that keeps them honest — is what the Hardware Track
later in this series is for. For now, the one thing worth keeping straight:
"atomic" doesn't mean one fix for one kind of bug. It means "pick the
guarantee your specific operation is missing," and std::atomic<T> gives
you both, whichever one your code actually needed.
The fix: std::atomic<bool>
struct atomic {
std::atomic<bool> ready{ false };
};
Same loop, same while (!data.ready) { }, only the type changes — and it
works at every optimization level, every time. For this specific bug, that's
entirely Job 2 doing the work: no more caching ready in a register across
iterations, and a guaranteed, ordered handoff of the write from one core to
the other.
std::atomic isn't "the fix for wrong numbers" or "the fix for hangs"
specifically — it's a toolbox with two tools in it, and which one your bug
needs depends on whether the operation you're protecting is multiple steps
(atomicity) or a single step whose visibility isn't guaranteed (ordering).
Wrong numbers, infinite hangs, and skipped checks are just different
costumes the same root cause — an unsynchronized shared variable — can wear,
depending on which tool was actually missing.
Why this matters more than the Part 1 bug did
Part 1's bug was loud, in a sense — the number was wrong, and if you had a
known-correct baseline to compare against (like we did), you'd catch it. This
bug is worse precisely because its behavior isn't even stable across
builds. Code that hangs at -O1 might look completely fine at -O0 in a
debug build during development, then hang in a release build in production.
Code that races at -O2 might not even wait for the condition it was
written to wait for, and silently proceed on data that isn't ready yet —
no crash, no hang, no obvious signal that anything went wrong at all.
"It worked when I tested it" was never proof of anything for a data race.
It's proof that this specific compiler, at this specific optimization
level, on this specific run, didn't happen to apply the optimization that
breaks you. Change any one of those three things and the same source code
can behave completely differently.
Takeaways
-
std::atomicdoes two distinct jobs, not one: atomicity (making a multi-step operation likecnt++indivisible, via a locked instruction) and visibility (making a single-step write's completion reliably reach other cores, plus stopping the compiler from caching stale reads). - Part 1's
cnt++needed atomicity — it's three instructions with a gap to race in. This article'sready = trueneeded visibility — it's already one instruction, nothing to make more indivisible. - The exact failure mode (hang, instant skip, or correct-by-luck) can change across optimization levels for the same source file — there is no single "how it breaks," which is what makes this category of bug so hard to trust-by-testing.
- Debug builds (
-O0) hiding this class of bug entirely is a real and common trap — "no threading bugs in debug mode" tells you nothing about release mode.



Top comments (0)