Memory Consistency Models

Coherence keeps a single location honest: everyone agrees on the order of writes to X. But real programs touch many locations, and the moment two threads read and write X and Y, a new and much harder question appears: in what order does each thread see the other's operations across different variables? That question is memory consistency, and it is where computer architecture reaches up and grabs the programmer by the collar.

This is the most software-facing lesson in the module. Get it wrong and your lock-free code has a bug that appears once a week on one customer's machine and never in your tests. The rules that decide what reorderings the hardware may perform — and how you, the programmer, can forbid them — are the memory consistency model.

Coherence vs consistency — the crucial distinction

Keep the slogan: coherence is about one variable; consistency is about the story across variables.

Sequential consistency: the intuitive ideal

The model every programmer wishes the hardware provided is sequential consistency (SC), defined by Leslie Lamport: the result of any execution is the same as if all cores' operations were executed in some single interleaved order, and within each core the operations appear in program order. In plain words: pretend the machine has one memory and one clock, and the threads simply take turns, never reordering their own instructions. It is exactly the mental model you use when you reason about threads on a whiteboard.

SC is beautifully simple to think about — and painfully expensive to build. It forbids the processor from letting a later load overtake an earlier store, even to a different address, even when doing so would be completely safe for a single thread. That reordering is one of the biggest sources of performance in a modern core (it hides store latency behind independent loads). Enforcing SC means giving that up, so essentially no mainstream processor implements it.

The reordering that breaks intuition

Here is the canonical example. Both x and y start at 0. Two threads run:

\text{T1: } x \leftarrow 1;\ r_1 \leftarrow y \qquad\qquad \text{T2: } y \leftarrow 1;\ r_2 \leftarrow x

Under sequential consistency, can both threads end with r_1 = 0 and r_2 = 0? Think it through: for r_1 = 0, T1's read of y happened before T2 wrote y; for r_2 = 0, T2's read of x happened before T1 wrote x. Those two demands contradict each other — impossible under SC. The code below enumerates every SC interleaving to prove (0,0) never appears:

// Enumerate every sequentially-consistent interleaving of: // T1: (a) x=1 (b) r1=y T2: (c) y=1 (d) r2=x // Program order must hold within each thread: a before b, c before d. function interleavings(): string[][] { const out: string[][] = []; function go(a: number, c: number, acc: string[]): void { if (a === 2 && c === 2) { out.push(acc.slice()); return; } if (a < 2) go(a + 1, c, acc.concat(a === 0 ? "x=1" : "r1=y")); if (c < 2) go(a, c + 1, acc.concat(c === 0 ? "y=1" : "r2=x")); } go(0, 0, []); return out; } const seen = new Set<string>(); for (const order of interleavings()) { let x = 0, y = 0, r1 = 0, r2 = 0; for (const step of order) { if (step === "x=1") x = 1; else if (step === "y=1") y = 1; else if (step === "r1=y") r1 = y; else if (step === "r2=x") r2 = x; } seen.add(`(r1=${r1}, r2=${r2})`); } console.log("SC-reachable outcomes:", [...seen].sort().join(" ")); console.log("Is (r1=0, r2=0) possible under SC? ", seen.has("(r1=0, r2=0)"));

Yet on real x86, ARM and POWER hardware, (0,0) does happen. Why?

Why: the store buffer

The culprit is the store buffer. When a core executes x \leftarrow 1, it does not wait for the write to reach cache — that could take dozens of cycles. It drops the store into a small FIFO buffer and moves on immediately to the next instruction, the read of y. So the load of y can complete before the store of x is visible to anyone else. The load has effectively overtaken the store. Do this in both threads and you get (0,0) — a result no interleaving of the original program allows.

This specific relaxation — a load may bypass an earlier store to a different address — is called Total Store Order (TSO), and it is what x86 actually implements. TSO keeps almost all of SC's guarantees (stores from one core still reach others in order) but permits exactly this store→load reorder, because the store buffer is too valuable to give up.

A spectrum of models — and the fence

Architectures sit on a spectrum from strict to relaxed. The more reorderings a model allows, the faster the hardware and the more careful the programmer must be.

ModelReorderings allowedProgrammer burdenReal example
Sequential (SC)nonetrivial to reason about(essentially none in HW)
TSOstore→later-load onlylow — need a fence for that one casex86-64, SPARC TSO
Weak / releasealmost any, unless a fence forbids ithigh — fences/acquire-release everywhere sharedARM, POWER, RISC-V

The programmer's tool for clawing back order is the memory fence (a barrier — x86's \texttt{MFENCE}, ARM's \texttt{DMB}). A fence forbids the hardware from moving accesses across it: put an \texttt{MFENCE} between the store and the load in the example, and it drains the store buffer first, killing the (0,0) outcome. In modern code you rarely write fences by hand — you use acquire/release atomics (a lock's release "publishes" everything before it; an acquire "sees" it), and the compiler emits the right fences for you. Every mutex, every lock-free queue is built on exactly this.

Because relaxation is performance, and it is worth the most where power matters most. ARM and RISC-V chose weak models precisely to let the hardware reorder aggressively and skip the bookkeeping TSO needs — cheaper, cooler, simpler cores, which is gold in a phone. The bet is that ordinary code is already race-free (protected by locks), and only the tiny amount of lock-free and synchronisation code needs explicit fences. The C++11 and Java memory models exist to make this bearable: you write to the language's model with \texttt{atomic} and acquire/release, and the compiler translates to whatever fences the target ISA needs. Write the fences right and weak hardware is both correct and fast; write them wrong and you get the bug that only reproduces in production.

The most dangerous misconception here: "the caches are coherent, so my threads will see consistent values." No. Coherence guarantees nothing about the ordering across different variables. The flag-based handoff \texttt{data = 42; ready = true;} in one thread and \texttt{while(!ready){} use(data);} in another can still break on relaxed hardware: the reader may see \texttt{ready} become true while \texttt{data} still reads its old value, because the two stores were reordered. Coherence keeps each variable honest; only a fence or a release/acquire pair orders them relative to each other. Never rely on coherence to order different locations.