Concurrency Roadmap/Memory Models & Atomics

Happens-Before, Visibility & Reordering

A write in one thread is not guaranteed to ever become visible to another without an explicit happens-before edge — compiler reordering, CPU reordering, and per-core caching are three independent, real reasons why, and 'it worked when I tested it' proves nothing about a memory-visibility bug.

!!4/5Theory: 45m
Language-specific mechanics: Concurrency Language Manual — Atomics, volatile & the Memory Model

The question this topic actually answers

Every primitive you've studied so far — mutexes, semaphores, condition variables — works because of something you've been trusting without examining: that when one thread writes a value and releases a lock, another thread that later acquires that lock will actually see the write. That guarantee feels obvious. It is not free, and it is not automatic. It is the product of a specific, formal contract between your source code, the compiler, and the CPU, called a memory model. This subtopic is about that contract — what it promises, what it doesn't, and why violating it produces the single nastiest class of bug in concurrent programming.

Here's the claim that should feel uncomfortable the first time you hear it: a plain write to a shared variable in one thread is not guaranteed to ever become visible to another thread, even if the writing thread finishes "immediately" and the reading thread polls in a tight loop forever. Not "might take a while" — genuinely, formally, undefined. To see why, you have to separate three independent mechanisms that can each cause this, any one of which is sufficient on its own.

Three independent causes of invisibility

1. Compiler reordering. A compiler is free to reorder, merge, hoist, or eliminate memory accesses as long as the single-threaded observable behavior of that thread is unchanged. If a thread writes data = 42 and then ready = true, and nothing in that thread's own control flow depends on the order between them, an optimizing compiler is within its rights to swap them, cache ready in a register and never write it back inside a loop, or eliminate a "redundant-looking" read of a variable it doesn't know is shared. The compiler has no concept of "another thread might be watching" unless you tell it so explicitly.

2. CPU-level (hardware) reordering. Even if the compiler emits instructions in program order, modern CPUs execute out of order and use store buffers to hide memory latency: a store can sit in a per-core buffer and be applied to the visible memory system later, while later instructions (including loads) proceed. Different architectures make different promises here — x86/x64 is comparatively strongly-ordered (loads and stores are mostly kept in order, with the notable exception of stores being reorderable past later loads), while ARM and POWER are weakly-ordered and will reorder much more aggressively unless told not to. Code that "just happens to work" because it was tested only on x86 is a landmine waiting for an ARM deployment.

3. Per-core caching. Each core typically has its own L1/L2 cache. A write from core A updates core A's cache line; without a cache-coherence protocol action (and, critically, without your code emitting the right memory barrier/fence instruction to trigger one), core B's cache can keep serving a stale copy of that line indefinitely. Coherence protocols like MESI do eventually propagate changes, but the "eventually" and the exact interleaving are not something your program can rely on without explicit synchronization — that's the whole point of a memory model: it tells you what ordering you're guaranteed, not what a particular implementation happens to do today.

These three causes are independent and compound. Disabling one (say, running on x86 with its stronger hardware guarantees) does not save you from another (the compiler still reorders). This is precisely why naive "I tested it on my laptop and it never failed" reasoning is worthless here — a bug can be latent because your compiler's optimization level was low, your specific CPU microarchitecture happened to keep things closely ordered, or your test never generated enough contention to expose the interleaving. Ship the same code to a different JIT tier, a different CPU vendor, or under real production load, and the bug appears — often as an intermittent, unreproducible, "impossible" failure months after launch.

Happens-before: the formal contract

Because "reordering is allowed unless forbidden" is true but useless on its own, every memory model defines a happens-before relation: a partial order over actions in your program such that if action A happens-before action B, then A's effects (including ordinary, non-atomic writes) are guaranteed visible to B. Critically, happens-before is not about wall-clock time — it says nothing about which action executes "first" in real time. It only says: if the relation holds, visibility and ordering are guaranteed; if it doesn't, you get no guarantee at all, even if A obviously ran earlier by the clock on the wall.

The relation is built from a small set of composable rules, present in some form in every mainstream memory model (Java, C++11, and hardware memory models all express variants of these):

  • Program order rule: within a single thread, each action happens-before every later action in that thread's own program order.
  • Monitor lock rule: releasing a lock happens-before every subsequent acquisition of that same lock, by any thread. This is the rule that makes mutexes work at all — it's not "the CPU knows about your mutex," it's "the lock/unlock operations are specified to insert exactly the barriers needed to establish happens-before."
  • Volatile / atomic rule: a write to a volatile (Java) or suitably-ordered atomic (C++'s std::atomic with acquire/release or stronger) variable happens-before a subsequent read of that same variable that observes the written value.
  • Thread start/join rule: starting a thread happens-before any action in that thread; every action in a thread happens-before another thread successfully joining it.
  • Transitivity: if A happens-before B and B happens-before C, then A happens-before C — this is what lets a lock or a volatile flag act as a "publication" mechanism for an entire batch of unrelated writes that came before it in program order.

The Java Memory Model (formalized in JSR-133) and the C++11 memory model (std::memory_order) are the two most precisely specified, widely referenced instances of this idea — worth knowing by name even outside their specific languages, because interviewers use them as the common vocabulary for "does this really synchronize, or does it just look like it does."

The trap: passing tests proves almost nothing

A program with a missing happens-before edge is not "usually correct with occasional glitches" — it is a program whose behavior on the shared variable is formally undefined, and the fact that it appeared correct during testing tells you only that your test didn't happen to expose a legal reordering. The bug is often invisible until: a different JIT compilation tier kicks in (interpreted vs. C1 vs. C2 in the JVM optimize the code differently), the code moves to a CPU with a weaker memory model, contention increases enough that a store buffer actually gets exercised, or the machine simply runs long enough for a rare interleaving to occur. This is the core reason "I ran it a thousand times and it was fine" is not evidence of correctness for a memory-visibility bug the way it might be for an ordinary logic bug — you're not sampling from "does the logic work," you're sampling from "did I get unlucky enough to see the compiler/CPU's actual freedom being exercised."

Pitfalls and interview framing

  • Confusing atomicity with visibility. These are orthogonal properties this whole topic exists to separate — see the next subtopic for the classic bug that results from conflating them.
  • Assuming a single global "now." Happens-before is a partial order, not a timeline; two actions with no happens-before edge between them are not "unordered in time," they're simply not comparable at all as far as the memory model's guarantees go.
  • Trusting an architecture's strong defaults. x86's relatively strong ordering hides real bugs that surface immediately on ARM (mobile, Apple Silicon, most cloud ARM instances) or on any sufficiently aggressive compiler.
  • Forgetting transitivity is what makes publication work. A common senior-interview question is "why does making just the last field volatile make an entire object safely published?" — the answer is the combination of the volatile-write happens-before rule and transitivity, not anything special about that one field.
Reference implementations in:

Unsynchronized Flag: A Visibility Bug Tests May Never Catch

A worker thread polls a plain (non-volatile/non-atomic) boolean set by another thread. Whether — and when — the loop ever notices the flag flip depends on the compiler and CPU, not your code's logic.

type Worker struct { running bool // deliberately NOT atomic — this is the bug } func (w *Worker) stop() { w.running = false } // written by main func (w *Worker) run() { spins := 0 for w.running { // read by the worker goroutine spins++ } fmt.Println("Stopped after", spins, "spins") } w := &Worker{running: true} go w.run() time.Sleep(100 * time.Millisecond) w.stop()

There is no happens-before edge between stop()'s write and run()'s read — the Go Memory Model (go.dev/ref/mem) allows the compiler to hoist w.running out of the loop, producing a genuine infinite loop. go test -race flags this as a data race; the fix is atomic.Bool (next subtopic), not a volatile keyword — Go has none.

Broken Double-Checked Locking: Reordering Exposes a Half-Built Object

The classic pre-JSR-133 singleton bug: without a happens-before edge on the instance field, another thread can observe a non-null reference to an object whose constructor hasn't finished running from its point of view.

var ( instance *Singleton mu sync.Mutex ) // BUG: the unlocked nil-check races with the write of instance. func getInstance() *Singleton { if instance == nil { // 1st check, no lock mu.Lock() defer mu.Unlock() if instance == nil { instance = &Singleton{} // publication without happens-before } } return instance } // FIX: sync.Once is the idiomatic singleton — not a hand-rolled DCL. var once sync.Once func getInstanceOnce() *Singleton { once.Do(func() { instance = &Singleton{} }) return instance }

The unlocked read of instance is a data race; even if it looks like Java's DCL, Go has no volatile to patch the publication. Don't try to rescue this with atomic.Pointer unless you specifically need a lock-free structure — sync.Once is the standard-library answer, and it establishes the happens-before edge from Do's function to every subsequent caller.

Further Resources (Optional)