Why Your Loads and Stores Happen Out of Order
Two independent agents reorder your memory operations before any other core sees them: the compiler, which rewrites the code, and the CPU, which executes and retires it out of order. Neither is malfunctioning, and on one core neither is detectable.
Software view, hardware view
The gap between what you wrote and what the machine does is where this whole domain lives.
Four reorderings, and who permits which
There are exactly four ways two memory operations can be reordered relative to each other, named for the program order of the pair: Load→Load, Load→Store, Store→Store and Store→Load. A memory model is, at heart, a statement about which of these four a machine is allowed to make visible to other observers.
x86-64 is unusually strict. Its model is essentially Total Store Order: it permits only Store→Load reordering — a later load may appear to execute before an earlier store. The other three are forbidden. This single permitted reordering is exactly the store-buffer effect from Sequential Consistency: The Model You Already Have, and it is why so much sloppy concurrent code appears to work on desktop and server hardware.
AArch64 and POWER permit all four. A store to data and a later store to ready can become visible to another core in the opposite order, with no fence and no atomic involved. This is not an obscure corner: it is ordinary behaviour on the phone in your pocket and on a large fraction of cloud instances. Code that assumed x86 semantics does not degrade gracefully here — it produces a torn or uninitialised read.
| Reordering | Sequential consistency | x86-64 (TSO-like) | AArch64 | POWER |
|---|---|---|---|---|
| Load → Load | No | No | Yes | Yes |
| Load → Store | No | No | Yes | Yes |
| Store → Store | No | No | Yes | Yes |
| Store → Load | No | Yes | Yes | Yes |
The compiler got there first
Focusing on the CPU misses half the problem. By the time the processor sees your code it has already been rewritten by an optimiser working under the as-if rule: any transformation is legal provided the observable behaviour of a single-threaded program is unchanged. Two stores to different objects are freely reorderable under that rule. So are a load hoisted out of a loop, a redundant load eliminated entirely, and a store sunk past a branch.
That last one deserves emphasis, because it defeats the most common home-made fix. A spin loop reading a plain bool may be compiled to load the flag once, into a register, and then loop forever on the register — the load has been hoisted out of the loop as an invariant, which is entirely correct for a single-threaded program. No amount of CPU-level fencing helps, because the load is no longer being performed.
This is why the fix has to be expressed in the language, not in assembly intuition. An atomic access, or a lock, tells the compiler that this location is concurrently accessed and constrains what it may do — and separately causes it to emit whatever CPU fences the target requires. One mechanism, both problems. Reaching for inline assembly or a hand-rolled fence addresses the CPU and leaves the compiler free to undo you.
SOURCE WHAT THE COMPILER MAY EMIT
------ --------------------------
data = 42; store ready = true <-- reordered:
ready = true; store data = 42 two independent
stores, single-thread
behaviour unchanged
CONSUMER SOURCE WHAT THE COMPILER MAY EMIT
--------------- --------------------------
while (!ready) { } load r = ready <-- hoisted out of
use(data); L: if (!r) goto L the loop; the flag
use(data) is never re-read
AND THEN, INDEPENDENTLY, THE CPU
--------------------------------
even with both stores emitted in program order, on AArch64 the
store to 'ready' may become visible to another core before the
store to 'data' drains, because nothing orders themWhy a single thread never notices
Both the compiler and the CPU are required to preserve the appearance of program order for the thread performing the operations. If you store to a location and then load it, you will read your own store — the CPU forwards it out of the store buffer without waiting for it to drain. Data dependencies through registers are respected. Single-threaded programs are therefore completely insulated from all of this, which is precisely why the intuition survives long enough to become a bug.
The guarantee evaporates the moment a *second* observer exists. Another core has its own cache and its own view, and nothing in the single-thread guarantee says anything about when your stores become visible to it. The reordering was always happening; you simply had no instrument capable of detecting it.
The practical consequence is a testing problem as much as a coding one. Single-threaded tests cannot detect these bugs even in principle. Multi-threaded tests on x86-64 can only detect the Store→Load class. Only weakly-ordered hardware, a thread sanitiser, or formal reasoning will find the rest — which is why "it passes CI" is such weak evidence for lock-free code.
1// Producer // Consumer2data = compute(); while (!ready) { }3ready = true; use(data);4 5// Compiler may reorder the two stores.6// Compiler may hoist the 'ready' load out of the loop.7// CPU may make 'ready' visible before 'data' on AArch64.8// Broken on every mainstream ISA, including x86-64.1// Producer // Consumer2data = compute(); while (!ready.load(acquire)) { }3ready.store(true, release); use(data);4 5// 'release' orders the earlier store to 'data' before6// the store to 'ready'. 'acquire' orders the load of7// 'ready' before the later read of 'data'. Together they8// create the happens-before edge the code always needed.The fix is not "add a fence" but "declare the synchronisation". A release store paired with an acquire load constrains the compiler *and* causes it to emit the right CPU-level ordering for the target — on x86-64 that is often no extra instruction at all, and on AArch64 it is a specific ordered store and load. One source, correct on both.
Key points
- There are exactly four reorderings — Load→Load, Load→Store, Store→Store, Store→Load — and a memory model is a statement about which are permitted.
- x86-64 permits only Store→Load; AArch64 and POWER permit all four, so x86-only testing cannot find most weak-memory bugs.
- The compiler reorders under the as-if rule before the CPU ever sees the code, and can delete a spin-loop load entirely.
- Both layers preserve program order as observed by the thread itself, which is why a single thread can never detect any of it.
- The fix belongs in the language — an atomic or a lock constrains the compiler and emits the right CPU fences together.
Follow the mechanism
The path through the machine, hop by hop — and the conclusions it invites that are wrong.
- 1Source → compiler: two stores to unrelated objects are reorderable under the as-if rule; the optimiser may emit them in either order or eliminate one.
- 2Compiler → machine code: a loop-invariant load of a plain flag may be hoisted out of the loop entirely, so the flag is read once into a register.
- 3Machine code → CPU front end: instructions are fetched and decoded in program order, preserving the illusion at the architectural level.
- 4CPU → store buffer: each store retires into the buffer and drains toward coherent cache independently, so visibility order need not match retirement order.
- 5Store buffer → other core: the second core observes whichever store drained first, which on AArch64 may be either one.
- • "The CPU reordered my code" — the compiler is at least as likely to be responsible, and only the language-level fix addresses both.
- • "x86 does not reorder" — it permits Store→Load, which is exactly the reordering behind the store-buffer litmus test and behind most Dekker-style mistakes.
- • "I added a memory fence in inline assembly, so it is fixed" — the compiler may still hoist, sink or eliminate the surrounding accesses.
- • "Reordering only matters for lock-free code" — it matters for any shared data touched without synchronisation, which includes plenty of accidentally-shared code.
Consequences, controls and cost
- • The publish pattern — initialise, then set a ready flag — is broken without synchronisation on every mainstream ISA, and is one of the most common idioms in systems code.
- • A spin loop on a plain flag can hang forever because the load was hoisted, not because the flag was never written.
- • Bugs surface as "works on our servers, fails on ARM" or "fails only in release builds", because the optimiser and the ISA both changed.
- • Adding logging or a debugger usually makes the symptom vanish, since both introduce synchronisation and drain buffers.
- • Use language-level atomics or locks for every location touched by more than one thread — this is the only fix that constrains the compiler and the CPU together.
- • Prefer acquire/release pairs to raw fences: they express *what* is being synchronised, and the compiler picks the cheapest correct encoding per target.
- • Never use `volatile` for inter-thread synchronisation in C or C++; it constrains optimisation of the access but provides no ordering or atomicity between threads.
- • Test on weakly-ordered hardware, and run a thread sanitiser in CI — an x86-only test suite is blind to three of the four reorderings.
- • Compile the publish pattern with optimisation on and read the emitted assembly to see whether the stores were reordered or the load hoisted.
- • Run a message-passing litmus test on AArch64 and count outcomes where `ready` is observed true and `data` is stale.
- • Compare the disassembly of a release store on x86-64 against AArch64 to see how differently the same source is encoded.
- • Enable a thread sanitiser and let it report the unsynchronised accesses directly rather than inferring them from symptoms.
- • Ordered atomics cost fences on weakly-ordered targets and inhibit compiler optimisation around them, which can matter in a hot loop.
- • Relaxed orderings recover that performance but move the correctness burden entirely onto you, with no tooling that reliably checks it.
- • Testing on multiple architectures costs CI capacity, and is still only evidence rather than proof.
Scope
§224 — what these claims are specific to.
- ISA-SPECIFICWhich of the four reorderings are permitted is defined per architecture: x86-64 allows Store→Load only; AArch64 and POWER allow all four.
- PLATFORM-SPECIFICCompiler reordering is governed by the language standard and optimisation level, not the CPU. The same source at
-O0and-O2can differ in exactly the ways described here.
Misconceptions
volatile is a genuine acquire/release, which is a large part of why the confusion persists.Where the rest of this lives
This lesson explains which reorderings hardware and compilers may perform. Which pairs of operations your program has actually ordered, and what that proves about its correctness, is the concurrency domain's subject.
What the optimiser is permitted to do to your code — and why deleting a loop-invariant load is legal — is a language-semantics question rather than a hardware one.