JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Why Shared Memory Needs a Memory Model

You already know two threads can race on the same variable. The deeper shock is this: even when each thread's code looks obviously correct, the compiler and the CPU are quietly reordering your reads and writes — and without a memory model, you cannot even say what the right answer is.

The comfortable lie you have been told

Up to now you have pictured memory as a single, honest notebook. Thread A writes a line, thread B reads it, and if B reads after A wrote, B sees what A wrote. That picture is what your whole intuition about shared mutable state rests on, and it is a lie — a useful one, but a lie. On a real machine there is no single notebook that all threads read and write in lockstep. There are caches, write buffers, an out-of-order CPU, and an optimizing compiler, and every one of them is allowed to make your program look like it ran in your written order without it actually doing so. The memory model is the contract that tells you exactly how big that gap is.

You met the symptom already in an earlier rung: a data race is two threads touching the same location, at least one writing, with nothing ordering them. The standard fix was a mutex, and you took on faith that locking "works." This rung is where we open that box. To understand why a mutex is enough — and why an ordinary unguarded shared variable is not, even when it looks fine — you first have to accept one uncomfortable fact: the order in which your memory operations actually become visible to other threads is not the order in which you wrote them.

Two reorderers, working against your intuition

There are two separate culprits, and it helps enormously to keep them apart. The first is the compiler. Under the as-if rule, the compiler may transform your code in any way that gives the same observable result for a single thread running alone. It can hoist a load out of a loop, sink a store past an unrelated one, keep a variable in a register and never write it back, or swap two independent statements. None of that changes a single-threaded result, so the compiler does it freely at -O2 — and that is exactly why a concurrency bug can vanish at -O0 and appear at -O2. This is not the compiler being naughty; it is doing precisely what the standard permits.

The second culprit is the CPU itself, and this one surprises people who thought "the machine runs my assembly in order." It does, as seen by the running core — but not as seen by other cores. The clearest example is the store buffer: when a core executes a store, the value does not go straight to shared memory — it parks in a small per-core queue and drains later, so the storing core can keep running without waiting. The result is that your own store can be invisible to another core for a while after you executed it, and a load that comes after your store in program order can complete before that store is visible elsewhere. This is compiler versus CPU reordering: two layers, two sets of rules, both bending the order, and a memory model has to cover both at once.

A tiny example that has no "obvious" answer

Here is the classic that breaks intuition, called store buffering. Two shared variables x and y both start at 0. Thread 1 writes x = 1 then reads y into a register r1. Thread 2 writes y = 1 then reads x into r2. Stare at it: surely at least one thread does its write before the other does its read, so at least one of r1, r2 must come out 1. On a single honest notebook, yes. On real hardware, no.

initially:  x = 0,  y = 0

Thread 1            Thread 2
  x = 1;              y = 1;
  r1 = y;             r2 = x;

single-notebook intuition:  never (r1 == 0 and r2 == 0)
real x86 (TSO):             r1 == 0 and r2 == 0  CAN happen
weakly-ordered ARM:         even more reorderings allowed
Store buffering. Each core's write sits in its store buffer while it does the following read, so both reads can see the old 0. The outcome your single-notebook intuition calls impossible is allowed even on x86 — and ARM is looser still.

Walk the mechanism without any magic. Thread 1's x = 1 lands in core 1's store buffer; thread 2's y = 1 lands in core 2's store buffer. Now thread 1 reads y from shared memory — but core 2's y = 1 is still stuck in core 2's buffer, so thread 1 sees 0, giving r1 = 0. Symmetrically thread 2 reads x, but core 1's x = 1 is still in core 1's buffer, so r2 = 0. Both reads completed before either store became globally visible. No instruction was skipped and no value was corrupted; the machine simply does not guarantee that your store is visible to others the instant you execute it. This is real, observable behavior even on the relatively strong memory ordering of x86 — and on a weakly-ordered ARM chip, even more surprising interleavings become legal.

What a memory model actually gives you

Faced with that mess, a language has two jobs. First, it must define an abstract rulebook so that the same source program has defined behavior whether it ends up on x86, on ARM, or on some chip not yet built. That rulebook is the memory model, and it is written in terms of one ideal and several relaxations of it. The ideal is sequential consistency: the program behaves as if there were one global interleaving of all threads' operations that respects each thread's own program order — the single honest notebook, restored as a guarantee you can ask for. It is the easiest model to reason about and, on weak hardware, the most expensive to provide.

Sequential consistency is the dream, but most real code does not need the whole-program version of it. What it needs is a way to say "this particular write in thread A must be seen before this particular read in thread B." The model captures that with a relation called happens-before: a partial order across the whole program saying which operations are guaranteed to be visible to which others. If A happens-before B, then B sees everything A did; if neither happens-before the other, all bets are off and you have a race. The only way to forge a happens-before edge between threads is to pair up special operations so that one synchronizes-with the other — exactly the mechanism the rest of this rung builds out. Hold the name happens-before; it is the load-bearing idea of the whole rung.

Second — and this is the deep idea this whole rung circles back to — the model draws a bright line called the data-race-free guarantee. The deal is breathtakingly clean: if your program contains no data races, then it behaves as if it were sequentially consistent, and you may keep your comfortable single-notebook intuition. But if it contains even one data race, the behavior is undefined — not "slightly wrong," but undefined behavior in the full, dangerous sense, where the optimizer is entitled to assume the race never happens. The store-buffering example above is only allowed to misbehave because its x and y are plain non-atomic variables racing. The model's promise is conditional, and the condition is: do not race.

How to hold all this in your head

So the way out is not to outlaw all reordering — that would throw away the performance the whole machine was built for. The way out is to give you a few special operations whose ordering is guaranteed, and let you fence around the moments that matter. Those operations are atomics, and the knob that selects how much ordering each one carries is the memory_order family you meet next. A mutex, it turns out, is just a well-packaged pair of these: lock and unlock carry exactly the happens-before edges that make everything you do between them safe. Let us assemble the mental model you will carry through the rest of this rung, in order, so each later guide snaps onto a slot you already have.

  1. Drop the single-notebook picture for shared data. Between threads, there is no global order of plain reads and writes you can count on; the compiler and the CPU both reorder.
  2. Recognize the two reorderers as distinct: the compiler under the as-if rule, and the CPU with its store buffer and out-of-order execution. A correct program must tame both.
  3. Treat the data-race-free guarantee as your contract: no races means your code behaves sequentially consistent; one race means undefined behavior, full stop.
  4. When two threads must share a location, build a happens-before edge between them — through an atomic with a chosen memory_order, or a higher-level primitive like a mutex built from atomics. Never through a plain racing variable.