The question this answers
Who reordered my code — the compiler, the CPU, or neither — and what actually stops it?
The same publication pattern as What a Memory Model Defines, compiled with optimizations enabled and run on more than one architecture.
A payload variable and a flag, both plain.
Single-threaded observable behaviour is unchanged by any reordering; cross-thread, only synchronization constrains what another thread may observe.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
The as-if rule, and the loop that never ends
Compilers are permitted to do anything that preserves the observable behaviour of the program *as if* it had executed exactly as written — for a single thread of execution. Hoisting a load out of a loop, sinking a store past a branch, merging two writes to the same location, keeping a variable in a register and never writing it back: all legal, all routine, all invisible in a single-threaded program.
The example below is the sharpest one, because the symptom is not a wrong value but a hang. A loop spinning on a plain bool reads it once, the compiler observes that nothing in the loop body modifies it, and hoists the load into a register. The loop becomes if (!ready) for(;;);. The other thread's write becomes visible and the spinning thread has stopped looking. This is not a hypothetical compiler; it is standard loop-invariant code motion.
The optimization level matters, which is why this class of bug behaves so badly in practice: the debug build works, the release build hangs, and attaching a debugger changes the timing enough to alter the symptom. See Heisenbugs: The Bug That Leaves When You Look at It.
1// ---- what you wrote ----2bool ready = false;3int data = 0;4 5void consumer() {6 while (!ready) { /* spin */ } // plain read of a plain bool7 use(data);8}9 10// ---- what the compiler may legitimately emit ----11void consumer_optimized() {12 // Nothing in this function modifies 'ready', so the load is13 // loop-invariant and is hoisted out. This is the as-if rule14 // applied correctly: single-threaded behaviour is unchanged.15 bool r = ready;16 if (!r) { for (;;) { } } // infinite loop; memory is never re-read17 use(data); // may also be hoisted ABOVE the loop18}19 20// ---- the fix, stated at the LANGUAGE level ----21std::atomic<bool> ready{false};22int data = 0;23 24void consumer_fixed() {25 while (!ready.load(std::memory_order_acquire)) { }26 use(data); // ordered after the producer's release27}Two reorderers, one fix
The compiler is the first reorderer and it operates at build time. The CPU is the second and it operates at execution time — it may issue loads and stores out of order and make its writes visible to other cores in an order different from the one in which the program issued them. Which reorderings a given CPU permits is architectural: x86-64 is strongly ordered and hides most of them, while 64-bit ARM, POWER and RISC-V permit substantially more. The mechanism belongs to Computer Architecture and is handed off in bridges rather than taught here.
The important engineering point is that you do not need to know which one bit you. Both are constrained by the same act: using the language's synchronization construct. Declaring the flag atomic and using release/acquire tells the compiler it may not move those accesses across the barrier and causes it to emit whatever instructions the target needs to constrain the hardware. One statement, both layers.
This is also why volatile is the wrong tool in C and C++. It prevents the compiler from eliminating or merging accesses to that object — which fixes the hoisted-loop symptom — and it constrains neither the ordering of other variables nor the hardware. It was designed for memory-mapped I/O registers, not for inter-thread communication. Java's volatile is a different keyword with genuine memory-model semantics; the two share a spelling and nothing else.
1volatile bool ready = false;2int data = 0;3 4// producer5data = 42;6ready = true; // compiler will not eliminate this store,7 // but may still move the 'data' write across it,8 // and the hardware is entirely unconstrained.9 10// consumer11while (!ready) { } // re-reads memory each iteration (the one thing12 // volatile buys) ...13use(data); // ... and may still observe data == 0.1std::atomic<bool> ready{false};2int data = 0; // plain; the pair below orders it3 4// producer5data = 42;6ready.store(true, std::memory_order_release);7 8// consumer9while (!ready.load(std::memory_order_acquire)) { }10use(data); // guaranteed 42In C and C++, volatile is about accesses to a location that may change outside the program's control — device registers. It carries no inter-thread ordering or visibility guarantee, so it fixes only the most visible symptom and leaves the actual bug. The atomic is the construct the memory model defines edges over. Java's volatile is unrelated despite the identical spelling and does carry memory-model semantics.
What another thread may observe
The schedule below shows the store-store case: two writes issued in one order, observed by another thread in the other. It is written as an observation rather than a mechanism deliberately — the trace records what B saw, not which layer produced it, because the program cannot tell and neither can you from a log.
The four canonical reorderings are store-store, load-load, load-store and store-load. Which of them a given architecture permits differs, and each language's ordering constructs are specified to forbid the ones that would break the edge you asked for. The practical takeaway is a discipline, not a table: never reason about which reorderings your target permits; specify the edge you need and let the toolchain forbid whatever must be forbidden.
One corollary that catches experienced engineers: a build that works on x86-64 has demonstrated very little. x86-64 forbids store-store and load-load reordering in hardware, so a missing edge frequently produces no visible symptom there and produces one immediately on ARM. Testing on one architecture is not testing this class of bug at all.
| # | Thread A | Thread B | State |
|---|---|---|---|
| 1 | issue store data = 42 | · | A issued=data=42 B observes data=0 |
| 2 | issue store ready = true | · | A issued=data=42, ready=true B observes data=0 B observes ready=false |
| 3 | · | load ready -> true | B observes ready=true B observes data=0 |
| 4 | · | load data -> 0 | B observes data=0 ✕ B observed the stores in the reverse of the order A issued them. Legal without an edge, and indistinguishable from compiler reordering, hardware reordering, or a stale cached read. |
| 5 | [with release on ready] issue release store ready = true | · | A issued=release(ready=true) |
| 6 | · | [with acquire] load ready -> true; load data -> 42 | B observes data=42 |
Key points
- Two independent reorderers exist: the compiler at build time and the CPU at run time. Both preserve single-threaded observable behaviour and neither preserves cross-thread order.
- A spin on a plain flag can be hoisted into a register, turning the loop into an infinite one — the symptom is a hang, not a wrong value.
- You cannot tell from the symptom which layer reordered, and you do not need to: the language's synchronization construct constrains both.
- C/C++
volatileprevents access elimination and provides no ordering or visibility guarantee. It is not a threading tool. Java'svolatileis a different thing with the same spelling. - Working on x86-64 is weak evidence: it forbids in hardware several reorderings that ARM permits, so missing edges routinely hide there.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • The compiler analyses each thread in isolation and applies any transformation preserving that thread's observable behaviour: hoisting, sinking, merging, register allocation, dead-store elimination.
- • The CPU issues and completes memory operations in an order its architecture permits, which may differ from program order, and makes writes visible to other cores according to its own rules.
- • Neither layer knows about your invariant, because neither can see the other thread.
- • An atomic access with an ordering constraint tells the compiler which movements are forbidden across it, and causes it to emit whatever instructions the target requires to forbid the corresponding hardware reorderings.
- • On a target that already forbids a reordering in hardware, the compiler emits nothing extra — so the portable construct costs nothing where the guarantee is already free.
- • A issues data=42 then ready=true; B observes ready=true then data=0. Reordering observed; no edge, so legal.
- • B spins on a plain flag whose load has been hoisted; the write becomes visible and B loops forever. The reordering here removed the read entirely.
- • A's use(data) is hoisted above the spin loop by the compiler, so B reads data before the loop even begins. Same class, different transformation.
- • With release/acquire: no schedule permits B to observe ready=true and data=0, on any target. The edge forbids exactly those observations.
- • Same source built at -O0: works. Built at -O2: hangs. Nothing about the program changed, which is what makes this class so expensive to diagnose.
- • Promises: your own thread always observes its own operations in program order. Reordering is never visible to the thread doing it.
- • Promises: with a properly paired ordering construct, the reorderings that would break the edge are forbidden at both layers.
- • Does NOT promise: that source order is observable by any other thread absent an edge.
- • Does NOT promise: that a given reordering will occur. These are permissions, not behaviours — which is why the bug appears intermittently and under specific builds.
- • Does NOT promise: that
volatilein C or C++ helps. It constrains access elimination only. - • Does NOT promise: that a working x86-64 build is portable. Absence of a symptom on a strongly ordered target is not absence of the bug.
- • Ordering constraints are not contention, but they do cost: on a target needing explicit barriers, a fence prevents the CPU from overlapping work it would otherwise have overlapped.
- • Sequential consistency is the most expensive ordering on such targets; acquire/release is cheaper and is what most publication patterns actually need.
- • A spin loop on an atomic reads the same line continuously, which is real coherence traffic even though nothing is being written. See Busy Waiting.
- • Infinite spin — a loop on a plain flag whose load was hoisted; presents as a hang with a thread at 100% CPU.
- • Stale read after a visible flag — the reordering case, presenting as a wrong or default value.
- • Works-in-debug — the bug appears only at higher optimization levels, so it survives development and appears in production. See Heisenbugs: The Bug That Leaves When You Look at It.
- • Architecture-dependent appearance — clean on x86-64, immediate on ARM, so it is discovered by a platform port rather than by testing.
- • Data race — in C++, any of the above is undefined behaviour, which means the compiler may optimise on the assumption it cannot happen, producing transformations that look inexplicable.
- • Reordering itself is a large part of why compiled code is fast; the goal is not to prevent it but to constrain it exactly where an edge is needed.
- • Knowing that it exists is what makes "just add a sleep" and "just add a log line" recognisable as non-fixes that change timing rather than semantics.
- • It explains why the same code behaves differently across build configurations and architectures, which otherwise looks like the toolchain being unreliable.
- • When it motivates blanket sequential consistency on every atomic in a hot path that was never measured.
- • When it motivates reasoning about the target architecture rather than the language, which produces code that is correct only by accident on one platform.
- • When it motivates
volatileas a threading fix in C or C++, which addresses one symptom and leaves the bug.
- • Build at production optimization levels in CI. A debug-only test suite has essentially no power against this class.
- • Test on 64-bit ARM as well as x86-64. This is the single highest-yield action available for finding missing edges.
- • Run a thread sanitizer, which reports the missing happens-before relation directly rather than the reordering symptom.
- • Read the generated assembly for the publication path on a weakly ordered target — the barrier should be visible. If the source uses an atomic and no barrier appears, check the ordering argument.
- • Treat "adding a log line makes it go away" as a positive diagnosis, not a mystery: the log call is an optimization barrier and often an implicit synchronization point. See Heisenbugs: The Bug That Leaves When You Look at It.
- • Correctness becomes build-configuration-dependent and architecture-dependent, which multiplies the CI matrix required to have any detection power.
- • The team needs enough shared understanding that nobody "simplifies" an atomic back to a plain variable during a cleanup.
- • Ordering arguments must be written down near the code, because they are not recoverable from reading it.
- • Any performance tuning of orderings creates a second correctness argument layered on the first.
- • A mutex, which forbids the relevant reorderings at both layers and requires no reasoning about which ones. See Mutexes: What They Protect and What They Do Not.
- • A queue or channel, where the send/receive pair supplies the constraint. See Channels.
- • Structuring so that the data is published once before the reader thread is started — thread creation is an edge, and it is free.
- • Immutable data plus a single atomic pointer publication, which reduces the ordering surface to exactly one location. See Immutability as a Concurrency Strategy.
- • A higher-level runtime that removes shared memory from the design: separate processes, worker agents with message passing. See Web Workers and Process versus Thread.
Both threads read 0, and both wrote first
x = y = 0
Thread 1 Thread 2
x = 1; y = 1;
r1 = y; r2 = x;
Sequential reasoning: one of the two stores must land first,
so at least one load must see a 1. r1 == 0 && r2 == 0 is impossible.| # | Thread 1 | Thread 2 | State |
|---|---|---|---|
| 1 | x ← 1 | · | x=1 y=0 r1=0 r2=0 |
| 2 | r1 ← y | · | x=1 y=0 r1=0 r2=0 |
| 3 | · | y ← 1 | x=1 y=1 r1=0 r2=0 |
| 4 | · | r2 ← x | x=1 y=1 r1=0 r2=1 |
Publish a value, then a flag — which edge makes it visible?
Writer Reader
data = 42; while (ready == 0) { }
ready = 1; use(data);| # | Writer | Reader | State |
|---|---|---|---|
| 1 | data ← 42 | · | data=42 ready=0 reader sees=— |
| 2 | ready ← 1 (plain store) | · | data=42 ready=1 reader sees=— |
| 3 | · | read ready → 1 | data=42 ready=1 reader sees=— |
| 4 | · | read data → 0 | data=42 ready=1 reader sees=0 ✕ the reader observed ready = 1 and data = 0 — it saw the flag that announces the write without seeing the write |
What people believe, and what is true
The compiler would not reorder my code like that.
Loop-invariant code motion, dead-store elimination and register promotion are among the most basic optimizations there are, and every one of them can produce this bug.
volatile makes it thread-safe in C++.
It prevents the compiler eliminating or merging accesses to that object. It provides no ordering with respect to other variables and no hardware guarantee at all.
It works on our servers, so it is fine.
x86-64 forbids in hardware several of the reorderings that expose this. The same binary logic on ARM — an M-series laptop, a Graviton instance — frequently fails immediately.
Go deeper
Overview
The compiler and the CPU may both move memory operations around, as long as your own thread cannot tell. Another thread can tell, unless you synchronize.
Practical
Never reason about which reorderings your target permits. State the edge you need with the language's construct and let the toolchain forbid whatever must be forbidden on that target.
Advanced
Acquire and release are one-way barriers: an acquire prevents later operations moving before it, a release prevents earlier operations moving after it. That asymmetry is why the publication pattern needs exactly one of each and not a full fence. See Memory Barriers Constrain Ordering, Not Caches.
Internals
The hardware side is store buffers, invalidation queues and speculative execution — a core commits a store to its buffer and continues before the store is globally visible, and loads may be satisfied speculatively and replayed. That mechanism is Computer Architecture material and is bridged rather than taught here.