Memory models: the rules behind happens-before

Seedling

4 min read

Write single-threaded code and it behaves as if each line runs in order, one after the next. The guarantee is so reliable it feels like a law of nature. It is not. It is a promise the compiler and CPU make to a single thread, and they keep it by any means they like, including running your instructions in a wildly different order underneath. The moment a second thread reads the same memory, the promise stops covering you, and you need a different one. That promise is the memory model.

Your code is not the order that runs

Between the code you wrote and the instructions that execute, two layers reorder for speed:

  • the compiler hoists, sinks, and reorders operations, as long as a single thread cannot tell the difference;
  • the CPU executes out of order, buffers writes in a store buffer before they reach cache, and lets each core view memory through its own caches.

All of it is invisible and correct for one thread. Across threads it becomes visible, and if you assumed program order, wrong. Take the textbook case. One thread writes:

data = 42;
ready = true;

another spins until it sees the flag, then reads the value:

while (!ready) {}
use(data);   // is data guaranteed to be 42 here?

Intuitively, if ready is true then data must already be set. Without a memory model that says so, it is not guaranteed. The writer’s two stores can become visible to the reader’s core in the other order, so the reader sees ready == true while data is still stale. Nothing is broken. The hardware is behaving exactly as designed for single-threaded speed.

Happens-before: the guarantee you actually get

A memory model defines one central relation: happens-before. If action A happens-before action B, then B is guaranteed to see everything A did. If two accesses to the same data have no happens-before edge between them and at least one is a write, you have a data race - and in most languages a data race on ordinary memory is undefined or unspecified behavior, not merely “an occasional stale read.”

You do not get happens-before for free. You establish it with synchronization: unlocking then locking a mutex, an atomic store-release paired with a matching load-acquire, a channel send received on the far side, a thread start or join. Each of these draws an edge the compiler and CPU are then forbidden to reorder across. Synchronization is not only about mutual exclusion; it is how you buy ordering guarantees at all.

Atomics are ordering, not just indivisibility

It is tempting to think an atomic variable only means “no torn reads.” The indivisibility is the small part. The important part is the ordering an atomic carries. An acquire load and a release store are exactly what turn the racy example above into a correct one, because they create the happens-before edge that forces data to be visible once ready is seen. A relaxed atomic gives you the indivisibility without the ordering - faster, and a footgun unless you know precisely why you want it.

The other way out: don’t share

All of this is the cost of sharing mutable memory between threads, so the cleanest way to get the ordering right is to not share at all. This is Go’s motto: do not communicate by sharing memory; share memory by communicating. A channel send that is received establishes a happens-before edge for free, so handing an object to another goroutine through a channel sidesteps the entire question of races on it (the scheduler underneath is its own note: The Go scheduler: G, M, and P). You have not repealed the memory model. You have arranged the program so you rarely have to reason about it.

The bottom line

A memory model is an invariant: the minimum you are allowed to assume about order and visibility once more than one thread is involved. The lesson is small and strict. You do not get to assume the order you wrote; you either establish order with synchronization, or you avoid shared mutable state so the question never arises. The concurrency bugs that surface only under load, on one machine, once a week, usually live in the gap where someone assumed a happens-before they never established.