The question this answers
What actually makes a write in one thread visible to a read in another?
A loader thread builds a routing table and publishes it; request threads read it on every request.
The table's fields and the reference that points at it.
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.
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.
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.
| # | Loader thread | Request thread | State |
|---|---|---|---|
| 1 | allocate table; write table.version = 7 | · | table.version=7 current=null |
| 2 | write table.routes = [400 entries] | · | table.version=7 table.routes=400 current=null |
| 3 | store current = &table (PLAIN store) | · | current=&table |
| 4 | · | load current (PLAIN load) -> &table, non-null | B.t=&table |
| 5 | · | read t->version -> 0 | B.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 -> 7 | B.version=7 |
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.
| Construct | The pair | What it orders | Notes |
|---|---|---|---|
| Mutex | unlock, then a later lock of the same mutex | Everything before the unlock, before everything after that lock | The reason plain fields under a mutex are safe |
| Release/acquire atomic | release store, acquire load of the same object reading that value | Everything sequenced before the store, before everything after the load | The cheapest explicit edge; both halves required |
| Sequentially consistent atomic | seq_cst store and load | As above, plus a single total order across all seq_cst operations | The default in C++ and in JS Atomics.* |
| Thread start | spawn, and the child's first operation | Everything the parent did before spawn, before everything the child does | Relied on constantly and rarely noticed |
| Thread join | the child's last operation, and the parent after join | Everything the child did, before everything the parent does after | Why reading a worker's results after join needs no atomics |
| Queue / channel send-receive | send, and the receive that returns that item | Everything before the send, before everything after the receive | Why message passing is harder to get wrong |
| Future / promise | resolve, and the await or get that observes it | Everything before the resolve, before everything after | See Futures & Promises |
| A sleep, a delay, a log line | No pair at all | Nothing | Time is not an ordering relation. This is the most common false belief in the module |
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.
- • 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.
- • 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.
- • 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.
- • 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.
- • 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.
- • 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 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.
- • 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.
- • 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.
- • 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?
Writer Reader
data = 42; while (ready == 0) { }
ready = 1; use(data);| # | Writer | Reader | State |
|---|---|---|---|
| 1 | data ← 42 | · | data=42 ready=0 reader sees=— |
| 2 | ready ← 1 (plain store) | · | data=42 ready=1 reader sees=— |
| 3 | · | read ready → 1 | data=42 ready=1 reader sees=— |
| 4 | · | read data → 0 | data=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 |
Both threads read 0, and both wrote first
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.| # | Thread 1 | Thread 2 | State |
|---|---|---|---|
| 1 | x ← 1 | · | x=1 y=0 r1=0 r2=0 |
| 2 | r1 ← y | · | x=1 y=0 r1=0 r2=0 |
| 3 | · | y ← 1 | x=1 y=1 r1=0 r2=0 |
| 4 | · | r2 ← x | x=1 y=1 r1=0 r2=1 |
What people believe, and what is true
My write happened first in time, so the other thread will see it.
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.
I made the published pointer atomic, so the object is safely published.
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.
Happens-before means one operation blocks until the other finishes.
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.