Thread A writes a value and sets a flag. Thread B waits for the flag, then reads the value.
// Thread Adata = 42;ready = true;
// Thread Bwhile (!ready) {}console.log(data); // 42? …not necessarily.0 is a legal output. So is looping forever, even after A has finished.
Thread A
data = 42; ready = true;
Thread B
while (!ready) {}
print(data);No happens-before edge exists between the two threads, so all three outcomes are legal. Your machine will pick the first one nearly every time, which is why this bug reaches production.
Why it can print zero
Two independent reasons, and both are the system working as designed.
The compiler reorders. data and ready are unrelated as far as it can
tell, so it may emit the stores in either order. It may also hoist ready out of
B’s loop into a register, making the loop infinite.
The CPU reorders. Stores sit in a write buffer and reach other cores in
whatever order is convenient. Core B can observe ready = true before it
observes data = 42.
The relation
Happens-before is a guarantee about visibility, and it is the only one you get:
If action X happens-before action Y, then everything X did is visible to Y.
If two actions are not ordered by it, they are concurrent, and neither is guaranteed to see the other. Notice what this is not: it is not about wall-clock time. A write that occurred earlier in real time is invisible unless a happens-before edge connects it.
Where the edges come from
Only from specific, named things:
Program order. Within one thread, earlier statements happen-before later ones. This is the guarantee that makes single-threaded code sane, and it is the only one you get for free.
Lock release → lock acquire. Everything before a release is visible after a subsequent acquire of the same lock.
Volatile / atomic write → read. Same shape, one variable.
Thread start. Everything before t.start() is visible to t.
Thread join. Everything t did is visible after t.join() returns.
Channel send → receive. In Go, a send happens-before the corresponding receive completes.
Transitivity. If X → Y and Y → Z, then X → Z. This is what makes the whole thing composable, and it is how most real edges are built: chains of small guarantees.
Fixing the example
// Thread Adata = 42;ready.store(true, memory_order_release); // publish
// Thread Bwhile (!ready.load(memory_order_acquire)) {} // subscribeconsole.log(data); // 42, guaranteedThe release-acquire pair creates the edge. The release says “everything I did before this is now published”; the acquire says “and I can see it”.
Crucially, data is an ordinary variable — the atomic operation on ready
carries the ordering for everything written before it. That is why one atomic
flag can safely publish a whole data structure.
Why this is worth learning once
Every concurrency primitive is a way of manufacturing happens-before edges. Once you see that, the rules stop being a list to memorise:
- Why does a mutex fix visibility? Release → acquire.
- Why is a non-volatile flag broken? No edge.
- Why is double-checked locking wrong without
volatile? The reader can see the reference before the object’s fields. - Why can a lock-free queue publish a node with one atomic store? The release carries the node’s writes with it.
- Why is
join()enough to read a worker’s results? Join is an edge.
The relation is also the same idea that orders events in a distributed system — Lamport’s 1978 paper defines happens-before for messages between machines, and the memory-model version is that idea applied to cores. Vector clocks, Raft terms and causal consistency are all built on it.