Memory Models & Visibility

Reordering: The Compiler and the CPU Both Do It

Two independent reorderers sit between your source and what another thread observes. Both are allowed to move memory operations as long as *your own thread's* observable behaviour is unchanged. Synchronization is the only thing that constrains either of them.

▶ Run the lab

The question this answers

The question

Who reordered my code — the compiler, the CPU, or neither — and what actually stops it?

The work

The same publication pattern as What a Memory Model Defines, compiled with optimizations enabled and run on more than one architecture.

What is shared

A payload variable and a flag, both plain.

The invariant — what must stay true under every interleaving

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.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

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 bool
7 use(data);
8}
9
10// ---- what the compiler may legitimately emit ----
11void consumer_optimized() {
12 // Nothing in this function modifies 'ready', so the load is
13 // loop-invariant and is hoisted out. This is the as-if rule
14 // applied correctly: single-threaded behaviour is unchanged.
15 bool r = ready;
16 if (!r) { for (;;) { } } // infinite loop; memory is never re-read
17 use(data); // may also be hoisted ABOVE the loop
18}
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 release
27}
What the compiler is entitled to do with a plain flag. The transformation is legal and standard.

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.

volatile: stops the compiler eliminating the load, orders nothing.
1volatile bool ready = false;
2int data = 0;
3
4// producer
5data = 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// consumer
11while (!ready) { } // re-reads memory each iteration (the one thing
12 // volatile buys) ...
13use(data); // ... and may still observe data == 0.
atomic with release/acquire: constrains the compiler AND the hardware.
1std::atomic<bool> ready{false};
2int data = 0; // plain; the pair below orders it
3
4// producer
5data = 42;
6ready.store(true, std::memory_order_release);
7
8// consumer
9while (!ready.load(std::memory_order_acquire)) { }
10use(data); // guaranteed 42

In 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.

B observes A's two stores in the opposite order to the one A issued them.SIMULATED
Invariant · If B observes ready == true, B observes data == 42
#Thread AThread BState
1issue store data = 42·A issued=data=42 B observes data=0
2issue store ready = true·A issued=data=42, ready=true B observes data=0 B observes ready=false
3·load ready -> trueB observes ready=true B observes data=0
4·load data -> 0B 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 -> 42B observes data=42
The program cannot distinguish which layer reordered, and it does not need to. One language-level edge constrains both, and reasoning about the target architecture instead is how portable code acquires an architecture-specific bug.

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++ volatile prevents access elimination and provides no ordering or visibility guarantee. It is not a threading tool. Java's volatile is 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.

How it works
  • 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.
Interleavings that matter
  • 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.
What it guarantees — and does not
  • 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 volatile in 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.
Where contention appears
  • 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.
How it fails
  • 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.
When it helps
  • 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 hurts
  • 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 volatile as a threading fix in C or C++, which addresses one symptom and leaves the bug.
How you would know
  • 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.
Complexity it introduces
  • 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.
Simpler alternatives
  • 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

Both threads read 0, and both wrote first
Two threads, two variables, four operations. Reason sequentially about it and one outcome is provably impossible: whichever store happened first, the other thread's load comes after it. Then let the load move up one line.
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.
0 of 6 schedules give (0,0)
r1=0, r2=0
never
r1=0, r2=1
1 schedule
r1=1, r2=0
1 schedule
r1=1, r2=1
4 schedules
Invariant · r1 == 0 && r2 == 0 cannot both hold — at least one thread must observe the other's store.
#Thread 1Thread 2State
1x ← 1·x=1 y=0 r1=0 r2=0
2r1 ← y·x=1 y=0 r1=0 r2=0
3·y ← 1x=1 y=1 r1=0 r2=0
4·r2 ← xx=1 y=1 r1=0 r2=1
As written, all 6 interleavings are enumerated above and none produces (0,0). That is what sequential consistency buys you: the program behaves as if there is one global order of operations that respects each thread's program order. Every argument you make about concurrent code by "walking through it" assumes this — and no mainstream language guarantees it for ordinary variables. The repair is not to reason harder about timing; it is to declare the ordering you need. A release store paired with an acquire load, a sequentially-consistent atomic, or an explicit fence turns the pair of accesses into a happens-before edge the compiler and the CPU must both respect. Ordering is something you *request*, and the cost of requesting it is the reason it is not the default.
CPU-SPECIFICWhether this reordering is observable depends on the processor, the compiler and the language memory model. x86 is strong and still permits exactly this case; ARM and POWER permit far more. The hardware mechanism — store buffers, invalidation queues, speculative loads — belongs to Computer Architecture; this lab only shows what the *program* can observe.

Publish a value, then a flag — which edge makes it visible?

Publish a value, then a flag
The writer fills in the data and sets a ready flag. The reader waits for the flag and reads the data. It looks airtight, and without a synchronization edge between the two threads it is not — no matter how long the reader waits.
Writer                          Reader
    data  = 42;                     while (ready == 0) { }
    ready = 1;                      use(data);
reader starts
edge
none
reader delay
immediately
values data may show
42 or 0
guarantee
none
Invariant · if the reader observes ready == 1, it observes data == 42.
#WriterReaderState
1data ← 42·data=42 ready=0 reader sees=—
2ready ← 1 (plain store)·data=42 ready=1 reader sees=—
3·read ready → 1data=42 ready=1 reader sees=—
4·read data → 0data=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
Without an edge the reader can observe ready = 1 and data = 0. Both writes happened, in that order, in the writer's source. The reader simply has no relation to them: the two plain stores may be published out of order, and the reader's two plain loads may be satisfied out of order or from a stale cached value. Happens-before is a partial order the language defines over operations, built from program order plus specific synchronizing pairs — a lock release and the next acquire of the same lock, a release store and the acquire load that reads it, a thread start, a thread join, a channel send and its receive. Two operations with no path between them in that order are concurrent, and a data race on them is undefined behaviour rather than a stale value: the compiler is entitled to assume it never happens. So the question to ask of any cross-thread visibility argument is never "could the other thread really be that fast?" — it is which edge makes this visible?
SIMPLIFIEDModelled at the level of a language memory model: an edge exists or it does not. Which spellings create one is language-specific — release/acquire in C++ and Rust, a lock or a volatile write in Java, a channel send or a lock in Go.

What people believe, and what is true

Claim

The compiler would not reorder my code like that.

Reality

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.

Claim

volatile makes it thread-safe in C++.

Reality

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.

Claim

It works on our servers, so it is fine.

Reality

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.

Apply it