Memory Models & Visibility

Happens-Before: The Edge That Makes a Write Visible

Happens-before is the relation that answers "will the other thread see it?". It is not about wall-clock time. It is a partial order built from specific paired constructs, and two operations with no path between them are unordered no matter which one ran first.

▶ Run the lab

The question this answers

The question

What actually makes a write in one thread visible to a read in another?

The work

A loader thread builds a routing table and publishes it; request threads read it on every request.

What is shared

The table's fields and the reference that points at it.

The invariant — what must stay true under every interleaving

Any thread that observes the published reference sees every field the loader wrote before publishing.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The edge, drawn

Happens-before is built from two ingredients. Within a thread, everything is ordered by program order — this is *sequenced-before*, and it is free. Between threads, an edge exists only where a specific pair of operations creates one — a release paired with an acquire on the same location, an unlock paired with the next lock of the same mutex, a thread start, a join. That is *synchronizes-with*. Happens-before is the transitive closure of the two.

The consequence is that the edge is a *pair*, never a single operation. A release store with nobody performing a matching acquire creates no edge. An acquire load of a location nobody released to creates no edge. This is why "I made the variable atomic and it still does not work" is such a common report: one half of the pair was added.

Once the edge exists, transitivity does the real work. Everything sequenced before the release in the writer is ordered before the acquire in the reader, and therefore before everything sequenced after it. The loader's four hundred field writes are covered by one release store, and no individual field needs to be atomic. That is the whole economy of the mechanism.

One synchronizes-with edge orders everything on both sides of it.
sequenced-beforesequenced-beforesequenced-beforeSYNCHRONIZES-WITH (the edge)sequenced-beforesequenced-beforeno edge: C did not acquirereads are unorderedA: table.routes = [...]A: table.version = 7A: table.builtAt = nowA: RELEASE store current = &tableB: ACQUIRE load currentC: plain load current (no acquire)B: read t->versionC: may read version = 0B: read t->routes
ClientGateway / LBServiceWorkerDatabaseCacheQueue / LogObject storageCDNExternal system

Without the edge, and with it

The schedule shows a reader that loads the published pointer with a plain load. It gets a non-null pointer — the store did become visible — and then reads a field that has not. This is the failure that makes "the reference was visible so the object must be ready" such a dangerous intuition: visibility of one location says nothing about any other.

The second half of the trace shows the same schedule with an acquire load. The read of version is now ordered after everything the writer sequenced before its release store, so it cannot observe the pre-construction value. Nothing about the timing changed; what changed is that an ordering relation now exists where before there was none.

This is why happens-before is not about time. Thread A's write can occur at 10:00:00.000 and thread B's read at 10:00:00.500 and B can still legally read the old value, if no edge relates them. "It ran first" is not an argument. "There is a path in the happens-before relation" is the only argument.

A publishes a constructed object. B loads plainly; then the same trace with an acquire.SIMULATED
Invariant · A thread observing the published reference sees all fields written before publication
#Loader threadRequest threadState
1allocate table; write table.version = 7·table.version=7 current=null
2write table.routes = [400 entries]·table.version=7 table.routes=400 current=null
3store current = &table (PLAIN store)·current=&table
4·load current (PLAIN load) -> &table, non-nullB.t=&table
5·read t->version -> 0B.version=0
✕ B holds a non-null pointer to an object whose fields it cannot see. No edge relates A's field writes to B's reads, so this observation is permitted.
6[with edge] store current = &table with RELEASE·current=&table
7·[with edge] load current with ACQUIRE -> &table; read t->version -> 7B.version=7
Publishing a pointer is not publishing an object. One paired edge covers every write sequenced before it, which is why a single release store is enough for an arbitrarily large object — and why a plain store is enough for none of it.

What actually creates an edge

The set is small and worth knowing by heart, because "which of these am I using?" is the question that resolves almost every visibility argument. Everything else — a sleep, a log line, a "it obviously ran first", a variable being const — creates nothing.

Note the two that people rely on without realising: thread creation orders everything the parent did before spawn against everything the child does, and a join orders everything the child did against everything the parent does after. A great deal of code is correct only because of these two edges, and it is worth knowing that is why.

Note also that queues and channels carry an edge. A value sent through a properly implemented concurrent queue is safely published to whoever receives it, which is a large part of why message passing is easier to get right than shared memory: the primitive supplies the edge and you cannot forget the other half. See Message Passing and Channels.

ConstructThe pairWhat it ordersNotes
Mutexunlock, then a later lock of the same mutexEverything before the unlock, before everything after that lockThe reason plain fields under a mutex are safe
Release/acquire atomicrelease store, acquire load of the same object reading that valueEverything sequenced before the store, before everything after the loadThe cheapest explicit edge; both halves required
Sequentially consistent atomicseq_cst store and loadAs above, plus a single total order across all seq_cst operationsThe default in C++ and in JS Atomics.*
Thread startspawn, and the child's first operationEverything the parent did before spawn, before everything the child doesRelied on constantly and rarely noticed
Thread jointhe child's last operation, and the parent after joinEverything the child did, before everything the parent does afterWhy reading a worker's results after join needs no atomics
Queue / channel send-receivesend, and the receive that returns that itemEverything before the send, before everything after the receiveWhy message passing is harder to get wrong
Future / promiseresolve, and the await or get that observes itEverything before the resolve, before everything afterSee Futures & Promises
A sleep, a delay, a log lineNo pair at allNothingTime is not an ordering relation. This is the most common false belief in the module
Paired constructs that create a happens-before edge — and the things that look like they do and do not.

Key points

  • Happens-before is a partial order: program order within a thread, plus synchronizes-with edges between threads, plus transitivity.
  • An edge is always a pair. A release with no matching acquire, or an acquire with no matching release, orders nothing.
  • One edge covers everything sequenced before it — which is why a single release store safely publishes an arbitrarily large object.
  • It is not about wall-clock time. A write that happened earlier in real time may still be invisible if no path relates the two operations.
  • Mutexes, release/acquire pairs, thread start and join, channel send/receive and future resolution create edges. Sleeps, logs and intuition do not.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • Within a thread, operation X is sequenced-before Y if X appears earlier in program order. This costs nothing and is always available.
  • Between threads, a release operation synchronizes-with an acquire operation that reads the value the release wrote — that specific pairing, on that specific location.
  • Happens-before is the transitive closure: if X is sequenced-before the release, and the release synchronizes-with the acquire, and the acquire is sequenced-before Y, then X happens-before Y.
  • A read is guaranteed to observe a write if that write happens-before it and no other write intervenes in the happens-before order.
  • Two conflicting accesses with no happens-before path between them constitute a data race, which in C++ is undefined behaviour.
Interleavings that matter
  • A writes fields; A stores the pointer plainly; B loads plainly and reads a field as zero. No edge, so the observation is legal.
  • A writes fields; A releases the pointer; B acquires and reads all fields correctly. One edge, arbitrarily many fields covered.
  • A releases; B acquires; B writes a field; C acquires the same pointer — C sees A's writes and B's writes, by transitivity through B's own release, if B released. Without B releasing, C sees only A's.
  • A releases at 10:00:00.000; C performs a plain load at 10:00:00.500 and reads a stale field. Half a second of wall clock creates no edge.
  • A writes fields inside a mutex and unlocks; B locks the same mutex and reads. Correct, with no atomics and no explicit ordering — the mutex pair is the edge.
  • A writes fields; A spawns B; B reads. Correct: thread creation is an edge, which is why worker initialisation via constructor arguments needs no synchronization.
What it guarantees — and does not
  • Promises: if X happens-before Y, then Y observes X's effect — subject to no intervening write.
  • Promises: the relation is transitive, so edges chain and one edge can cover an unbounded amount of preceding work.
  • Does NOT promise: a total order. Happens-before is partial; many pairs of operations are simply unordered, and that is not a defect.
  • Does NOT promise: anything about real time. Earlier in time does not imply happens-before, and happens-before does not imply earlier in time as observed by any clock.
  • Does NOT promise: an edge from a lone release or a lone acquire. Both halves must exist and must be on the same location.
  • Does NOT promise: mutual exclusion. An edge orders visibility; it does not stop two threads being in the same region at once. Those are different properties. See Mutexes: What They Protect and What They Do Not.
Where contention appears
  • Establishing an edge means making writes visible across cores, which is coherence traffic on the released line — the cost scales with how many threads acquire it.
  • A publication pattern where one thread releases rarely and many threads acquire often is the cheap case: the line is read-shared and stays in every core's cache between publications.
  • A pattern where many threads release the same location is the expensive case, because every release requires exclusive ownership. See What a Shared Write Costs.
  • Sequential consistency is the most constrained ordering and therefore the most expensive on architectures that need explicit fences; acquire/release costs nothing extra on x86-64.
How it fails
  • Stale read — the reference is visible and the object's fields are not. The signature failure of a missing edge. See Safe Publication: Handing Over a Finished Object.
  • One-sided synchronization — an atomic added on the writer's side only, so the code looks synchronized and orders nothing.
  • Data race — two conflicting accesses with no path between them; undefined behaviour in C++, an implementation-defined result elsewhere.
  • Broken transitivity — an intermediate thread that reads with acquire but republishes with a plain store, silently cutting the chain for everyone downstream.
  • Time-based reasoning — "the initialisation runs at startup, before any request thread exists" is often true and sometimes false, and it is never an edge unless the thread was started after the initialisation.
When it helps
  • Publishing large immutable structures cheaply: one release store covers the whole object, so no field needs to be atomic and no lock is taken on the read path.
  • Reviewing shared-memory code — asking "name the edge" produces either a specific answer or a bug, with no third outcome.
  • Justifying why a mutex-protected plain field is correct, which is the most common correct pattern in production code and is usually believed for the wrong reason.
  • Understanding why message passing is safer: the queue supplies both halves of the pair and there is no way to add only one.
When it hurts
  • When it becomes an excuse for hand-placed acquire/release in code where a mutex was clearer and not measurably slower.
  • When a chain of edges gets long enough that the argument spans several files, at which point nobody will re-verify it during the next change.
  • When applied to data that is never shared, adding ceremony and implying a sharing that does not exist.
How you would know
  • A thread sanitizer is precisely a happens-before checker: it reports conflicting accesses with no path between them, which is the definition of a missing edge. This is the one tool that directly measures the property.
  • Test on weakly ordered hardware. A missing edge is frequently invisible on x86-64 and routine on ARM.
  • Add an assertion on the reader side that the published object's fields are self-consistent — a version field checked against a magic number catches partially visible publication.
  • Review artefact rather than a runtime one: annotate each shared field with the construct that publishes it. A field with no annotation is the bug.
  • Stress with publication happening repeatedly under load rather than once at startup, since a one-time initialisation before threads exist is edge-covered by thread start and hides the problem. See Stress Testing: A Test That Passed Once Proves Nothing.
Complexity it introduces
  • The correctness argument lives in the relation, not in the code, so it cannot be verified by reading a single function.
  • Every shared field acquires an obligation to name its publishing construct, and that obligation must survive refactoring.
  • Transitive chains are fragile: an intermediate that fails to republish with a release breaks visibility for threads it never heard of.
  • The vocabulary itself is a barrier — release, acquire, synchronizes-with, sequenced-before — and code that depends on it is code that only some of the team can safely change.
Simpler alternatives
  • A mutex around both the write and the read. One construct, both halves impossible to forget, and everyone on the team already understands it. See Mutexes: What They Protect and What They Do Not.
  • A concurrent queue or channel, which supplies the edge as part of the send/receive and cannot be half-applied. See Channels.
  • Publish once before starting the threads, so thread creation is the edge and no explicit synchronization is needed at all.
  • An immutable object plus a single atomic pointer swap — the minimal correct pattern for read-mostly configuration. See Immutability as a Concurrency Strategy and Copy-on-Write as a Concurrency Strategy.
  • A higher-level primitive specified to establish the edge: a future, a latch, an Event. See Latches & Countdowns.

Publish a value, then a flag — which edge makes it visible?

Publish a value, then a flag
The writer fills in the data and sets a ready flag. The reader waits for the flag and reads the data. It looks airtight, and without a synchronization edge between the two threads it is not — no matter how long the reader waits.
Writer                          Reader
    data  = 42;                     while (ready == 0) { }
    ready = 1;                      use(data);
reader starts
edge
none
reader delay
immediately
values data may show
42 or 0
guarantee
none
Invariant · if the reader observes ready == 1, it observes data == 42.
#WriterReaderState
1data ← 42·data=42 ready=0 reader sees=—
2ready ← 1 (plain store)·data=42 ready=1 reader sees=—
3·read ready → 1data=42 ready=1 reader sees=—
4·read data → 0data=42 ready=1 reader sees=0
✕ the reader observed ready = 1 and data = 0 — it saw the flag that announces the write without seeing the write
Without an edge the reader can observe ready = 1 and data = 0. Both writes happened, in that order, in the writer's source. The reader simply has no relation to them: the two plain stores may be published out of order, and the reader's two plain loads may be satisfied out of order or from a stale cached value. Happens-before is a partial order the language defines over operations, built from program order plus specific synchronizing pairs — a lock release and the next acquire of the same lock, a release store and the acquire load that reads it, a thread start, a thread join, a channel send and its receive. Two operations with no path between them in that order are concurrent, and a data race on them is undefined behaviour rather than a stale value: the compiler is entitled to assume it never happens. So the question to ask of any cross-thread visibility argument is never "could the other thread really be that fast?" — it is which edge makes this visible?
SIMPLIFIEDModelled at the level of a language memory model: an edge exists or it does not. Which spellings create one is language-specific — release/acquire in C++ and Rust, a lock or a volatile write in Java, a channel send or a lock in Go.

Both threads read 0, and both wrote first

Both threads read 0, and both wrote first
Two threads, two variables, four operations. Reason sequentially about it and one outcome is provably impossible: whichever store happened first, the other thread's load comes after it. Then let the load move up one line.
x = y = 0

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

Sequential reasoning: one of the two stores must land first,
so at least one load must see a 1. r1 == 0 && r2 == 0 is impossible.
0 of 6 schedules give (0,0)
r1=0, r2=0
never
r1=0, r2=1
1 schedule
r1=1, r2=0
1 schedule
r1=1, r2=1
4 schedules
Invariant · r1 == 0 && r2 == 0 cannot both hold — at least one thread must observe the other's store.
#Thread 1Thread 2State
1x ← 1·x=1 y=0 r1=0 r2=0
2r1 ← y·x=1 y=0 r1=0 r2=0
3·y ← 1x=1 y=1 r1=0 r2=0
4·r2 ← xx=1 y=1 r1=0 r2=1
As written, all 6 interleavings are enumerated above and none produces (0,0). That is what sequential consistency buys you: the program behaves as if there is one global order of operations that respects each thread's program order. Every argument you make about concurrent code by "walking through it" assumes this — and no mainstream language guarantees it for ordinary variables. The repair is not to reason harder about timing; it is to declare the ordering you need. A release store paired with an acquire load, a sequentially-consistent atomic, or an explicit fence turns the pair of accesses into a happens-before edge the compiler and the CPU must both respect. Ordering is something you *request*, and the cost of requesting it is the reason it is not the default.
CPU-SPECIFICWhether this reordering is observable depends on the processor, the compiler and the language memory model. x86 is strong and still permits exactly this case; ARM and POWER permit far more. The hardware mechanism — store buffers, invalidation queues, speculative loads — belongs to Computer Architecture; this lab only shows what the *program* can observe.

What people believe, and what is true

Claim

My write happened first in time, so the other thread will see it.

Reality

Wall-clock order is not happens-before. Without a paired construct, the two operations are unordered and the reader may legally observe the old value.

Claim

I made the published pointer atomic, so the object is safely published.

Reality

Only if the reader acquires. An atomic release on one side with a plain read on the other creates no edge, and the object is not published.

Claim

Happens-before means one operation blocks until the other finishes.

Reality

It is a visibility and ordering relation, not a blocking one. Nothing waits; the relation constrains what may be observed.

Go deeper

Overview

For a write to be guaranteed visible to another thread, there must be a chain of paired synchronization constructs connecting them. That chain is happens-before.

Practical

For every shared field, name the construct that publishes it — this mutex, this release/acquire pair, this queue send, thread start. If nothing can be named, the field is racy even if the program currently works.

Advanced

Transitivity is the property that makes the mechanism affordable and the property that makes it fragile. One release covers unbounded prior work; one intermediate that republishes plainly severs the chain for everyone downstream.

Internals

A release store compiles to a plain store on x86-64 and to a store-release instruction or an explicit barrier on ARM. This is why the same source is correct on both and why the cost differs: the language edge is constant, the instructions implementing it are not.

Apply it