Examine the following four-line concurrent program. Two shared integer variables, x and y, are initialized to 0 in main memory. Two threads running on two separate CPU cores execute two instructions each—one store and one load:
| |
Thread 1 writes 1 to x and then reads y into r1. Thread 2 writes 1 to y and then reads x into r2. When both threads finish executing, what final values of (r1, r2) are possible?
Let us trace every conceivable timing scenario using common sense:
- If Thread 1 runs slightly ahead of Thread 2, Thread 1 sets
x = 1and readsr1 = 0, after which Thread 2 setsy = 1and readsr2 = 1. Result:(r1 = 0, r2 = 1). - If Thread 2 runs slightly ahead of Thread 1, Thread 2 sets
y = 1and readsr2 = 0, after which Thread 1 setsx = 1and readsr1 = 1. Result:(r1 = 1, r2 = 0). - If both threads execute their first line (
x = 1andy = 1) before either executes its second line, both threads read1. Result:(r1 = 1, r2 = 1).
Whichever thread goes first—or even if they run at the exact same nanosecond—at least one store (x = 1 or y = 1) must happen before the other thread’s final load. By elementary logic, the outcome r1 == 0 && r2 == 0 is mathematically impossible.
Yet if you compile this exact code today and run it in a tight loop on your Intel Core or AMD Ryzen laptop (x86-64), your Apple Silicon MacBook or smartphone (ARMv8), a RISC-V server, or an IBM Power machine—or compile it with an optimizing C/C++/Rust compiler (-O2 or -O3)—the “mathematically impossible” outcome r1 == 0 && r2 == 0 occurs thousands of times per second!
Your processor is not defective, nor is your compiler broken. Rather, the mental model that almost every programmer carries in their head—that instructions execute in the order written in the source code and that memory writes become instantaneously visible to all CPU cores—is an illusion.
Why do both modern microprocessors and optimizing compilers routinely scramble the order of memory operations? How can we write correct lock-free or concurrent code if x = 1; r1 = y; can execute backwards? In this article, we dismantle the illusion of program order from silicon up to programming language theory: Leslie Lamport’s Sequential Consistency (SC) and why it fails on the Store Buffering (SB) Litmus Test, the microarchitectural reality of Store Buffers, MESI Cache Coherency, and Invalidate Queues across x86-TSO vs. ARMv8/RISC-V/PowerPC, compiler transformations and the DRF-SC Theorem (Data-Race-Free Sequential Consistency, Adve & Hill 1990), the formal mathematics of Happens-Before ($\xrightarrow{hb}$) and Synchronizes-With ($\xrightarrow{sw}$), the spectrum of atomic memory orderings (Relaxed, Acquire-Release, SeqCst), and the exact machine-instruction mappings (MOV, XCHG, MFENCE, DMB ISH, LDAR/STLR).
Chapter 1: The Illusion of Program Order — Litmus Tests and Sequential Consistency
1.1 Leslie Lamport’s Sequential Consistency (1979)
In 1979, computer scientist Leslie Lamport formulated the exact mathematical property that human programmers intuitively assume when reasoning about multi-threaded programs, naming it Sequential Consistency (SC):
Definition — Sequential Consistency (Lamport, 1979): “A multiprocessor system is sequentially consistent if the result of any execution is the same as if the operations of all the processors were executed in some sequential order, and the operations of each individual processor appear in this sequence in the order specified by its program.”
Sequential Consistency requires two simultaneous invariants:
- Preservation of Program Order ($\prec_p$): Within any single thread, instructions take effect in the exact order specified by the source code.
- Single Global Memory Order ($\prec_m$ / Atomicity of Visibility): All memory reads and writes across all cores behave as if a single global switch connects one core at a time to a single shared RAM module; every write becomes visible to all cores simultaneously at a single point in the global timeline.
In concurrency verification, small 4- to 6-instruction concurrent programs designed to expose deviations from Sequential Consistency are called Litmus Tests. Our opening example—originating at the heart of Edsger Dijkstra’s and Theodorus Dekker’s 1965 mutual exclusion algorithm—is the canonical Store Buffering (SB) Litmus Test.
Let $S_1\ (x=1)$ and $L_1\ (r_1=y)$ denote the store and load of Thread 1, and let $S_2\ (y=1)$ and $L_2\ (r_2=x)$ denote the store and load of Thread 2. Preserving each thread’s internal program order ($S_1 \prec_p L_1$ and $S_2 \prec_p L_2$), there are exactly $\binom{4}{2} = \frac{4!}{2!2!} = 6$ valid ways to interleave these four operations into a single sequential timeline:
| Interleaving # | Global Execution Sequence | Resulting Registers (r1, r2) |
|---|---|---|
| Interleaving 1 | $S_1(x=1) \to L_1(r_1=y) \to S_2(y=1) \to L_2(r_2=x)$ | r1 = 0, r2 = 1 |
| Interleaving 2 | $S_1(x=1) \to S_2(y=1) \to L_1(r_1=y) \to L_2(r_2=x)$ | r1 = 1, r2 = 1 |
| Interleaving 3 | $S_1(x=1) \to S_2(y=1) \to L_2(r_2=x) \to L_1(r_1=y)$ | r1 = 1, r2 = 1 |
| Interleaving 4 | $S_2(y=1) \to L_2(r_2=x) \to S_1(x=1) \to L_1(r_1=y)$ | r1 = 1, r2 = 0 |
| Interleaving 5 | $S_2(y=1) \to S_1(x=1) \to L_2(r_2=x) \to L_1(r_1=y)$ | r1 = 1, r2 = 1 |
| Interleaving 6 | $S_2(y=1) \to S_1(x=1) \to L_1(r_1=y) \to L_2(r_2=x)$ | r1 = 1, r2 = 1 |
We can prove algebraically in three lines why r1 == 0 && r2 == 0 is impossible under Sequential Consistency:
For r1 == 0 to occur, the load $L_1(r_1=y)$ must execute in the global memory order strictly before the store $S_2(y=1)$, so $L_1 \prec_m S_2$. Symmetrically, for r2 == 0 to occur, the load $L_2(r_2=x)$ must execute strictly before the store $S_1(x=1)$, so $L_2 \prec_m S_1$. Chaining these with program order $S_1 \prec_p L_1$ and $S_2 \prec_p L_2$ yields a strict cycle $S_1 \prec L_1 \prec S_2 \prec L_2 \prec S_1$, contradicting the irreflexivity of a total order!
1.2 The Single-Threaded “As-If Rule”
Why, then, do both CPUs and compilers violate Sequential Consistency? Because enforcing Sequential Consistency across every ordinary memory read and write would make modern computers roughly an order of magnitude slower.
Instead, hardware architects and compiler writers optimize aggressively under the Single-Threaded “As-If Rule”: a CPU core or compiler may reorder, coalesce, buffer, or eliminate any instructions as long as the observable behavior of a single isolated thread executing that code remains identical.
Look at Thread 1 in isolation: x = 1; r1 = y;. Because x and y are distinct memory addresses (&x != &y), writing to x has zero effect on the value read from y within Thread 1. From Thread 1’s solitary perspective, executing r1 = y; first and committing x = 1; second produces the exact same single-threaded state! Only when a second thread concurrently observes x and y does the reordering become externally visible.
Chapter 2: Hardware Memory Models & Microarchitecture — Store Buffers, MESI, and Invalidate Queues
2.1 The Memory Wall and MESI Cache Coherency Latency
To understand why hardware reorders memory accesses, consider the physical latencies inside a modern 4 GHz microprocessor:
- CPU Register / ALU operation: $\sim 0.25\text{ ns}$ ($1$ clock cycle)
- L1 Data Cache hit (32–64 KB per core): $\sim 1\text{ ns}$ ($3\text{–}4$ cycles)
- L2 Cache hit (512 KB–1 MB per core): $\sim 3.5\text{ ns}$ ($12\text{–}14$ cycles)
- L3 Shared Cache / Cross-Core Coherency Handshake: $\sim 15\text{–}50\text{ ns}$ ($60\text{–}200$ cycles)
- Main DRAM Access: $\sim 80\text{–}100\text{ ns}$ ($300\text{–}400$ cycles)
Because each core caches 64-byte cache lines in its private L1/L2 caches, hardware maintains cache coherence across cores using variants of the MESI Protocol (Modified, Exclusive, Shared, Invalid). Under MESI, multiple cores may simultaneously hold clean read-only copies of a cache line in the Shared (S) state. However, before Core 0 is permitted to modify a cache line (x = 1), Core 0 must acquire exclusive ownership (transitioning the line to Modified (M)) by broadcasting an Invalidate / Read-For-Ownership (RFO) message across the on-chip interconnect to every other core holding x, and waiting until every other core purges its copy of x and replies with an Invalidate Acknowledge.
2.2 Store Buffers and Store-Load Reordering on x86/x64 (Total Store Order)
Suppose Core 0 executes x = 1; when x is currently cached in the Shared state (or not in Core 0’s L1 cache at all). Waiting 50 to 200 clock cycles for the cross-chip MESI Invalidate handshake to complete before moving to the next instruction would leave the CPU’s execution pipelines sitting idle most of the time!
To hide store latency, every modern CPU places a hardware FIFO queue called a Store Buffer (Write Buffer) between each CPU core’s execution units and its L1 Data Cache.
flowchart TD
subgraph Core0 ["CPU Core 0 (Thread 1: x = 1; r1 = y;)"]
Exec0["Execution Unit (Core 0)"]
SB0["Core 0 Private Store Buffer<br/>[Pending Entry: x = 1] (Invisible to Core 1!)"]
L1_0["Core 0 L1 Data Cache<br/>y = 0 (State: Shared)"]
Exec0 -- "1. Buffers store 'x = 1' in 1 cycle & moves on" --> SB0
Exec0 -- "2. Executes load 'r1 = y' from L1 immediately (r1 = 0!)" --> L1_0
SB0 -. "3. Drains 'x = 1' to L1 only after MESI RFO Ack arrives" .-> L1_0
end
subgraph Core1 ["CPU Core 1 (Thread 2: y = 1; r2 = x;)"]
Exec1["Execution Unit (Core 1)"]
SB1["Core 1 Private Store Buffer<br/>[Pending Entry: y = 1] (Invisible to Core 0!)"]
L1_1["Core 1 L1 Data Cache<br/>x = 0 (State: Shared)"]
Exec1 -- "1. Buffers store 'y = 1' in 1 cycle & moves on" --> SB1
Exec1 -- "2. Executes load 'r2 = x' from L1 immediately (r2 = 0!)" --> L1_1
SB1 -. "3. Drains 'y = 1' to L1 only after MESI RFO Ack arrives" .-> L1_1
end
L1_0 <== "MESI Cache Coherency Interconnect (RFO / Invalidate)" ==> L1_1
Now trace our Store Buffering (SB) Litmus Test cycle by cycle through the diagram above:
- Initially, both
x = 0andy = 0reside in both Core 0’s and Core 1’s L1 caches in theShared(S) state. - Core 0 executes
x = 1($S_1$). Instead of stalling for the MESI Invalidate handshake, Core 0 writesx = 1into Core 0’s private Store Buffer, initiates an asynchronous RFO request on the bus, and immediately advances to the next instruction! Crucially,x = 1is still sitting inside Core 0’s private Store Buffer—it has not yet reached the coherent L1 cache and is completely invisible to Core 1. - Simultaneously, Core 1 executes
y = 1($S_2$), writingy = 1into Core 1’s private Store Buffer (invisible to Core 0) and immediately advancing. - Core 0 executes
r1 = y($L_1$). Core 0 checks its own Store Buffer fory(not present) and then readsyfrom its L1 Data Cache. Becauseyis in theSharedstate in Core 0’s L1 cache with value0, the load hits in L1 immediately and setsr1 = 0! - Simultaneously, Core 1 executes
r2 = x($L_2$), hits onx = 0(Shared) in Core 1’s L1 Data Cache, and setsr2 = 0! - Dozens of cycles later, the MESI Invalidate handshakes finish and both Store Buffers drain
x = 1andy = 1into the L1 caches—too late to changer1andr2.
Thus, the simple presence of a per-core Store Buffer causes the load $L_1$ to take effect in the global memory hierarchy before the earlier store $S_1$ becomes visible to other cores! This is Store-Load Reordering, and it occurs on every x86/x64 processor.
What if Core 0 executes x = 1; r1 = x; (reading back the exact same variable it just wrote)? If Core 0 read stale x = 0 from its L1 cache while x = 1 sat in its Store Buffer, single-threaded programs would break! To preserve the single-threaded As-If Rule, hardware implements Store Buffer Forwarding (Store-to-Load Forwarding): every load first snoops the core’s own Store Buffer; if a pending store to the same address exists in the local Store Buffer, the core forwards that value directly to the load register without waiting for L1.
Intel and AMD formalize this behavior as the x86 Total Store Order (x86-TSO) memory model (Owens, Sarkar, & Sewell, 2009):
- Each core’s Store Buffer is a strict FIFO queue: stores leave the Store Buffer and enter the coherent cache hierarchy in the exact order they were issued (no Store-Store reordering).
- Loads are never reordered with other loads (no Load-Load reordering), nor are stores pushed ahead of earlier loads (no Load-Store reordering).
- Once a store drains from a Store Buffer into the L1 cache, it becomes visible to all other cores simultaneously (Other-Multi-Copy Atomicity).
- Only
Store -> Loadto different addresses is reordered (because a load can bypass an older store to a different address sitting in the local Store Buffer).
2.3 Invalidate Queues and Weakly Ordered Architectures (ARMv8, RISC-V, PowerPC)
While x86-TSO only permits Store -> Load reordering, mobile, embedded, and modern cloud processors—including ARMv8-A (AArch64 / Apple M-series / AWS Graviton), RISC-V (RVWMO), and IBM PowerPC—adopt Weakly Ordered Memory Models that permit all four combinations of memory reordering (Store-Load, Store-Store, Load-Load, and Load-Store) between independent addresses!
Why? Two microarchitectural mechanisms explain how Store-Store and Load-Load reorderings happen on weakly ordered chips:
- Non-FIFO Store Buffers & Cache Bank Parallelism: If Core 0 executes
data = 42; flag = 1;, and the cache line forflagis already in theModified/Exclusivestate in Core 0’s L1 cache whiledatamisses in L1, a weakly ordered core does not forceflag = 1to wait behinddata = 42—it commitsflag = 1to L1 immediately whiledata = 42is still waiting for its cache line! - Invalidate Queues and Speculative Out-of-Order Loads: Under high store traffic, processing incoming MESI Invalidate messages immediately against the L1 cache tags can bottleneck the cache port. Weakly ordered CPUs therefore place incoming Invalidate requests into a hardware Invalidate Queue and immediately send back an
Invalidate Ackto the writing core before actually invalidating the local L1 cache line! Furthermore, out-of-order execution pipelines speculatively execute later loads (r2 = data) dozens of cycles before earlier loads (r1 = flag) retire.
Consider the classic Message Passing (MP) Litmus Test:
- Thread 1 (Core 0): Writes
data = 42;then writesflag = 1;. - Thread 2 (Core 1): Waits until it reads
r1 = flagas1, and then readsr2 = data.
On ARMv8, RISC-V, or PowerPC without memory barriers, Thread 2 can observe r1 == 1 (the flag is set!) and yet read r2 == 0 (stale uninitialized data!)—either because Core 0 committed flag = 1 before data = 42 (Store-Store reordering), or because Core 1 speculatively read data = 0 from its L1 cache (or hadn’t processed the queued invalidation for data in its Invalidate Queue yet) before reading flag = 1 (Load-Load reordering)!
| Hardware Memory Model | Load -> Load Reordering | Load -> Store Reordering | Store -> Store Reordering | Store -> Load Reordering | Multi-Copy Atomicity (MCA) |
|---|---|---|---|---|---|
| Sequential Consistency (SC) | No | No | No | No | Full Multi-Copy Atomic |
| x86 / x64 (Intel / AMD TSO), SPARC TSO | No | No | No | Yes (Store Buffer) | Other-Multi-Copy Atomic |
| ARMv8-A (AArch64 / Apple Silicon) | Yes | Yes | Yes | Yes | Other-Multi-Copy Atomic (since ARMv8.0) |
| RISC-V (RVWMO) | Yes | Yes | Yes | Yes | Other-Multi-Copy Atomic |
| IBM Power / PowerPC | Yes | Yes | Yes | Yes | Non-Multi-Copy Atomic (Non-MCA) |
| DEC Alpha (Historical Extreme) | Yes (Even dependent loads p->x!) | Yes | Yes | Yes | Non-Multi-Copy Atomic |
(Note: IBM PowerPC is even Non-Multi-Copy Atomic—because pairs of cores share L2 caches before reaching the global interconnect, a write by Core 0 can become visible to Core 1 before becoming visible to Core 2, allowing the Independent Reads of Independent Writes (IRIW) litmus test to observe two stores in opposite orders on two reader cores! And the legendary 1992 DEC Alpha had partitioned cache banks that could reorder even data-dependent loads like p = global_ptr; r1 = p->field;, reading stale uninitialized p->field after reading the newly published pointer p!)
Chapter 3: Compiler Reordering and the DRF-SC Theorem — Why Data Races Are Undefined Behavior
3.1 Why Even an x86 CPU Cannot Save Unsynchronized Code
Programmers who work exclusively on x86-64 servers often fall into a dangerous trap: “Since x86-TSO hardware only reorders Store -> Load, my message-passing code data = 42; flag = 1; is safe without atomics on x86, right?”
Wrong! Long before your program’s machine instructions ever reach an x86 CPU core, the optimizing compiler (gcc, clang, rustc, javac) analyzes your source code under the single-threaded As-If Rule and freely reorders, caches, or deletes memory accesses across all four categories (Load-Load, Load-Store, Store-Store, Store-Load)!
Consider three routine compiler optimizations that shatter unsynchronized multi-threaded code:
- Instruction Scheduling & Store-Store / Load-Load Reordering:
In
data = 42; flag = 1;,dataandflagare distinct non-aliasing variables. Ifdatarequires computing a complex expression or spills to the stack, the compiler’s instruction scheduler is completely free to emit the store toflag = 1before the store todata = 42, breaking Message Passing even on x86! - Register Promotion & Loop-Invariant Code Motion (LICM):
Suppose Thread 2 waits for a flag using a plain
int flag = 0;variable:When compiling at1 2 3while (flag == 0) { // wait for Thread 1 to set flag = 1 }-O2, the compiler inspects the body of thewhileloop, sees that nothing inside the loop ever modifiesflag, and applies Loop-Invariant Code Motion (LICM)—hoisting the memory load offlagout of the loop into a CPU register once and for all:If Thread 2 enters1 2 3 4 5 6// What gcc/clang -O2 actually emits in assembly: int reg_eax = flag; // Load from RAM only ONCE! if (reg_eax == 0) { while (true) { // Infinite loop! Never reads 'flag' from RAM again! } }while (flag == 0)before Thread 1 setsflag = 1, Thread 2 locks up in an infinite loop forever! - Non-Atomic Load/Store Tearing & Speculative Invented Writes: An optimizing compiler may split a 64-bit write into two 32-bit writes (causing another thread to read a half-written “torn” value), or even use a shared global variable as temporary scratch storage inside a loop before writing the final value!
[!CAUTION] Why C/C++
volatileIs NOT for Multi-Threading! In C and C++, marking a variablevolatileonly tells the compiler not to optimize away or reorder accesses to that specificvolatilevariable relative to othervolatilevariables—a mechanism designed strictly for Memory-Mapped I/O (MMIO) hardware registers andsig_atomic_tsignal handlers. C/C++volatileemits zero CPU hardware memory fences (so the x86 Store Buffer and ARM Invalidate Queue still reorder it!) and does NOT prevent the compiler from reordering normal non-volatileaccesses (data = 42) across avolatileaccess (flag = 1)! (By contrast, in Java and C#, thevolatilekeyword does carry full Acquire-Release / Sequential Consistency synchronization semantics.)
3.2 The DRF-SC Theorem (Adve & Hill, 1990) and Data Races as Undefined Behavior
How can programming languages allow compilers and CPUs to optimize at full speed without forcing application programmers to reason about Store Buffers, Invalidate Queues, and LICM on every line of code?
In 1990, computer architects Sarita V. Adve and Mark D. Hill proved the foundational theorem of modern memory models: the DRF-SC Theorem (Data-Race-Free Sequential Consistency), later adapted by Hans Boehm and Sarita Adve (PLDI 2008) to form the bedrock of the C++11, C11, and Rust memory models.
First, let us state the formal definition of a Data Race:
Definition — Data Race: An execution contains a Data Race if two different threads access the same memory location concurrently, at least one of these accesses is a write (store), at least one is a non-atomic operation, and neither access Happens-Before ($\xrightarrow{hb}$) the other.
The DRF-SC Theorem establishes an ironclad contract between the programmer and the implementation (compiler + CPU):
- The Guarantee (DRF $\implies$ SC): If a program is Data-Race-Free (DRF)—meaning every pair of conflicting accesses to shared data is properly ordered by synchronization primitives (mutexes or atomic operations)—then every execution of the program is mathematically guaranteed to be indistinguishable from a Sequentially Consistent (SC) execution! Between synchronization points, the compiler and CPU can reorder instructions and buffer stores as aggressively as they wish, and no thread will ever be able to tell the difference.
- The Penalty (Data Race $\implies$ Undefined Behavior): If a C, C++, or Unsafe Rust program contains a Data Race on ordinary variables, the language standard declares the entire execution to be Undefined Behavior (UB)—because compiler optimizations like register promotion, dead-store elimination, and switch jump-table bounds elision are only sound under the assumption that non-atomic variables do not spontaneously mutate mid-expression from another thread.
Chapter 4: Formalizing Happens-Before ($\xrightarrow{hb}$), Synchronizes-With ($\xrightarrow{sw}$), and Atomic Memory Orderings
To eliminate data races and selectively constrain hardware and compiler reordering without always paying the cost of heavy locks, C++11 (std::atomic<T>), C11 (_Atomic), and Rust (std::sync::atomic) define a formal axiomatic memory model (formalized mathematically by Mark Batty et al., POPL 2011, and refined in RC11 by Ori Lahav et al., PLDI 2017).
4.1 The Relational Graph: $\xrightarrow{sb}$, $\xrightarrow{rf}$, $\xrightarrow{sw}$, and $\xrightarrow{hb}$
Every candidate execution of a program is modeled as a directed graph whose vertices are memory events—Reads $R(x, v)$, Writes $W(x, v)$, Read-Modify-Writes $RMW(x, v_{\text{old}}, v_{\text{new}})$, and Fences $F$—connected by four fundamental relations:
- Sequenced-Before ($\xrightarrow{sb}$): The intra-thread program order. If statement $A$ precedes statement $B$ in the control flow of a single thread $T_i$, then $A \xrightarrow{sb} B$.
- Modification Order ($\xrightarrow{mo}$): For each individual atomic variable $x$, all writes to $x$ across all threads occur in a single total order $\xrightarrow{mo}_x$ agreed upon by all threads (Cache Coherence per variable).
- Reads-From ($\xrightarrow{rf}$): Connects a write event $W(x, v)$ to the read event $R(x, v)$ that takes its value from that specific write: $W(x, v) \xrightarrow{rf} R(x, v)$.
- Synchronizes-With ($\xrightarrow{sw}$): The crucial inter-thread bridge! Whenever Thread 1 performs an atomic store to $x$ tagged with
releaseordering ($W_{\text{rel}}(x, v)$) and Thread 2 performs an atomic load from $x$ tagged withacquireordering ($R_{\text{acq}}(x, v)$) that reads the value written by $W_{\text{rel}}$ (or a later write in a release sequence), the runtime observation $W_{\text{rel}}(x, v) \xrightarrow{rf} R_{\text{acq}}(x, v)$ establishes a Synchronizes-With edge across threads: $$W_{\text{rel}}(x, v) \xrightarrow{sw} R_{\text{acq}}(x, v)$$ (Unlocking astd::mutexalso synchronizes-with the subsequentlock()of that same mutex.) - Happens-Before ($\xrightarrow{hb}$): Ignoring legacy
consumeordering, Happens-Before is simply the transitive closure of intra-thread Sequenced-Before ($\xrightarrow{sb}$) and inter-thread Synchronizes-With ($\xrightarrow{sw}$):
The Fundamental Visibility Axiom of $\xrightarrow{hb}$:
- For any non-atomic variable $d$, a read $R(d)$ is guaranteed to read the value written by $W(d, v)$ if $W(d, v) \xrightarrow{hb} R(d)$ and there is no intervening write $W'(d, v')$ such that $W(d, v) \xrightarrow{hb} W'(d, v') \xrightarrow{hb} R(d)$.
- Furthermore, because $W(d, v) \xrightarrow{hb} R(d)$, the two accesses are ordered by Happens-Before—so there is no Data Race!
4.2 Proving Message Passing Correct with Acquire-Release Synchronization
Let us apply this axiomatic machinery to fix the broken Message Passing (MP) pattern using std::memory_order_release and std::memory_order_acquire:
| |
Why is assert(data == 42) at (4) mathematically guaranteed never to fail on any CPU or compiler? Trace the directed graph of relations:
flowchart TD
subgraph Thread1 ["Producer Thread (Core 0)"]
W_data["(1) data = 42<br/>(Non-Atomic Write)"]
W_flag["(2) flag.store(1, memory_order_release)<br/>(Atomic Release Store)"]
W_data -- "Sequenced-Before (sb)<br/>[Release Barrier forbids (1) sinking below (2)!]" --> W_flag
end
subgraph Thread2 ["Consumer Thread (Core 1)"]
R_flag["(3) flag.load(memory_order_acquire) == 1<br/>(Atomic Acquire Load)"]
R_data["(4) assert(data == 42)<br/>(Non-Atomic Read)"]
R_flag -- "Sequenced-Before (sb)<br/>[Acquire Barrier forbids (4) floating above (3)!]" --> R_data
end
W_flag == "Reads-From (rf) + Release/Acquire Pair<br/>⟹ Synchronizes-With (sw) Established!" ==> R_flag
W_data -. "By Transitivity: Happens-Before (hb)!<br/>W(data=42) ⟶hb R(data)" .-> R_data
$$W(\text{data}=42) \xrightarrow{sb} W_{\text{rel}}(\text{flag}=1) \xrightarrow{sw} R_{\text{acq}}(\text{flag}=1) \xrightarrow{sb} R(\text{data})$$$$\implies W(\text{data}=42) \xrightarrow{hb} R(\text{data}) \implies R(\text{data}) = 42 \quad (\text{Guaranteed!})$$Notice the asymmetric, one-way nature of Release and Acquire barriers—often called Roach Motel Semantics (“instructions can check in, but they can’t check out”):
memory_order_releaseon Store(2)acts as a downward one-way barrier: no memory read or write sequenced before(2)(such asdata = 42) is allowed by the compiler or CPU to be reordered after(2).memory_order_acquireon Load(3)acts as an upward one-way barrier: no memory read or write sequenced after(3)(such as readingdata) is allowed by the compiler or CPU to be reordered before(3).
4.3 The Spectrum of Atomic Orderings: Relaxed, Acquire-Release, and SeqCst
Let us compare the three primary ordering levels available in C++, C, and Rust:
| Memory Ordering (C++ / Rust) | Guarantees Provided | Establishes $\xrightarrow{sw}$? | Reordering Permitted Around Operation | Canonical Use Cases |
|---|---|---|---|---|
memory_order_relaxed( Ordering::Relaxed) | Indivisible atomicity (no torn R/W) + per-variable Modification Order ($\xrightarrow{mo}$) coherence only. | No | All accesses to other variables may be freely reordered across a relaxed load/store! | Standalone metrics/counters (hits.fetch_add(1, Relaxed)), progress flags where no payload depends on the flag. |
memory_order_acquire / release / acq_rel( Ordering::Acquire / Release / AcqRel) | Pairwise visibility: all writes before a release store become visible after a thread’s acquire load reads that store. | Yes (pairwise) | Roach Motel: An earlier release store followed by a later acquire load CAN still be reordered (Store -> Load reordering allowed!). | Spinlocks (lock = acquire, unlock = release), SPSC queues, reference counting (fetch_sub(1, AcqRel)), publishing pointers. |
memory_order_seq_cst( Ordering::SeqCst — Default) | All Acquire-Release guarantees PLUS a single global total order $\xrightarrow{S}$ over all seq_cst operations across all threads. | Yes + Global $\xrightarrow{S}$ | None among seq_cst operations (forbids Store -> Load reordering by draining Store Buffers / full fences). | Dekker’s / Peterson’s mutual exclusion, Store Buffering (SB), multi-variable lock-free work-stealing deques (Chase-Lev). |
Let us highlight a subtle and crucial point: Why does Acquire-Release fail to prevent r1 == 0 && r2 == 0 in our opening Store Buffering (SB) Litmus Test?
Suppose we rewrite Thread 1 and Thread 2 using Release stores and Acquire loads:
| |
In Thread 1, the Release store S1 comes before the Acquire load L1. Remember Roach Motel semantics:
ReleaseonS1only forbids instructions aboveS1from moving belowS1.AcquireonL1only forbids instructions belowL1from moving aboveL1.- Nothing forbids
L1(below) from moving aboveS1(above)!
On x86-64, as we saw in the next chapter’s instruction table, store(release) and load(acquire) compile down to plain MOV instructions—so the x86 Store Buffer still delays x = 1 and y = 1, and r1 == 0 && r2 == 0 still occurs with Acquire-Release! Only when both stores and both loads are promoted to std::memory_order_seq_cst does the compiler emit the Store-Buffer-draining instructions needed to forbid r1 == 0 && r2 == 0.
Chapter 5: Hardware Instruction Mappings & Explicit Memory Fences (MFENCE, LOCK, DMB, LDAR/STLR)
How do compilers (clang, gcc, rustc) translate abstract C++/Rust atomic operations into concrete machine instructions across x86-64, ARMv8-A (AArch64), and RISC-V? The mapping table below reveals the exact bridge between software language theory and silicon hardware:
| C++ / Rust Atomic Operation | x86-64 (Intel / AMD TSO) | ARMv8-A (AArch64) | RISC-V (64-bit RVWMO) |
|---|---|---|---|
load(relaxed) | mov eax, [rdi] | ldr w0, [x0] | lw a0, 0(a0) |
load(acquire) | mov eax, [rdi] (Zero hardware fence cost!) | ldar w0, [x0] (Load-Acquire Register) | lw a0, 0(a0)fence r, rw |
load(seq_cst) | mov eax, [rdi] | ldar w0, [x0] | fence rw, rwlw a0, 0(a0)fence r, rw |
store(relaxed, v) | mov [rdi], esi | str w1, [x0] | sw a1, 0(a0) |
store(release, v) | mov [rdi], esi (Zero hardware fence cost!) | stlr w1, [x0] (Store-Release Register) | fence rw, wsw a1, 0(a0) |
store(seq_cst, v) | xchg [rdi], esi (or mov + mfence) | stlr w1, [x0] (Ordered with subsequent ldar) | fence rw, rwsw a1, 0(a0)fence rw, rw |
fetch_add(1, acq_rel) | lock xadd [rdi], eax | ldaxr + add + stlxr (or LSE ldaddal) | amoadd.w.aqrl |
atomic_thread_fence(acquire) | (Compiler barrier only: asm("")) | dmb ishld (Load-only barrier) | fence r, rw |
atomic_thread_fence(release) | (Compiler barrier only: asm("")) | dmb ish | fence rw, w |
atomic_thread_fence(seq_cst) | mfence (or lock or dword ptr [rsp], 0) | dmb ish (Full Inner-Shareable Barrier) | fence rw, rw |
Let us examine the three most profound architectural lessons in this table:
- On x86-64,
AcquireandReleaseAre Free in Hardware! Because x86-TSO hardware never reordersLoad -> Load,Load -> Store, orStore -> Store, anacquireload or areleasestore on x86-64 requires no hardware fence instruction whatsoever—it compiles to a single ordinarymovinstruction! Thememory_order_acquireandmemory_order_releaseannotations merely act as a Compiler Barrier (forbidding the compiler’s optimizer from reordering instructions across themovat compile time). This is why buggy concurrent C/C++ code that forgetsacquire/releaseannotations often “works by accident” during testing on x86 laptops, only to crash catastrophically in production when deployed on ARM64 cloud servers (AWS Graviton) or Apple Silicon! - Why
store(seq_cst)on x86-64 UsesXCHGorMFENCE: To preventStore -> Loadreordering inseq_cst, the x86 core must drain its Store Buffer before executing any subsequent load. Compilers achieve this either by emittingmov [rdi], esifollowed by an explicitmfence(Memory Fence) instruction, or—more commonly in modern GCC/Clang—by emittingxchg [rdi], esi, an atomic read-modify-write instruction with an implicit hardwareLOCKprefix. On x86-64, anyLOCK-prefixed instruction (lock xadd,lock cmpxchg,xchg) acts as a full hardware memory barrier that flushes the core’s Store Buffer to the L1 cache before allowing any subsequent memory access to proceed! - ARMv8-A’s Surgical
LDARandSTLRInstructions vs. HeavyDMB ISHFences: In older weakly ordered architectures (such as ARMv7 or PowerPC), implementingacquireorreleaserequired inserting a heavy standalone fence instruction (dmb ishon ARM,lwsync/syncon PowerPC) that stalled all memory accesses across the core’s pipeline. Designed hand-in-hand with the C++11 memory model committee, ARMv8-A (AArch64) introduced dedicatedLDAR(Load-Acquire Register) andSTLR(Store-Release Register) instructions:STLRensures that all earlier loads and stores from this core become globally visible before theSTLRitself becomes visible, without blocking later independent instructions.LDARensures that no later loads or stores execute untilLDARcompletes.- Crucially, the ARMv8 hardware specification mandates that an
LDARinstruction is never permitted to execute ahead of an earlierSTLRinstruction in program order! Thus, compilingstore(seq_cst)toSTLRandload(seq_cst)toLDARautomatically preventsStore -> Loadreordering in Dekker’sSBlitmus test without needing a singleDMB ISHfull barrier!
Conclusion: Mastering the Contract Between Silicon and Software
The discovery that x = 1; r1 = y; and y = 1; r2 = x; can yield r1 == 0 && r2 == 0 is a rite of passage for every systems programmer. Far from being an erratic hardware flaw, instruction reordering is the foundational architectural compromise that enables multi-gigahertz out-of-order processors and optimizing compilers to overcome the 300-cycle Memory Wall:
- Hardware Microarchitecture: To hide MESI cache-coherency RFO latency, every CPU core buffers writes in a private Store Buffer, causing
Store -> Loadreordering even on strong x86-TSO. Weakly ordered architectures (ARMv8, RISC-V RVWMO, PowerPC) additionally employ non-FIFO store merging, speculative loads, and Invalidate Queues, allowing all four reorderings (Load-Load,Load-Store,Store-Store,Store-Load). - Compiler Optimizations & DRF-SC: Under the single-threaded As-If Rule, compilers freely reorder stores and hoist loads into registers (turning
while (!flag)into an infinite loop). By the DRF-SC Theorem (Adve & Hill, 1990), any Data Race on non-atomic variables is Undefined Behavior, whereas any Data-Race-Free (DRF) program is guaranteed to behave with full Sequential Consistency. - Happens-Before & Atomic Orderings: By pairing
memory_order_releasestores withmemory_order_acquireloads, programmers construct inter-thread Synchronizes-With ($\xrightarrow{sw}$) bridges and transitive Happens-Before ($\xrightarrow{hb}$) chains—compiling to zero-overheadMOVs on x86-64 and lightweightLDAR/STLRinstructions on ARMv8—while reservingmemory_order_seq_cstand explicit fences (MFENCE,DMB ISH) for algorithms that require a single global total order across all cores.
