5.2.27 · D2C++ Programming

Visual walkthrough — Memory model — happens-before, acquire-release semantics

2,529 words11 min readBack to topic

Step 1 — Two threads, one shared box of memory

WHAT. Imagine your program's memory as a long shelf of numbered boxes. Each box holds one value. A thread is just a worker who walks along the shelf, reading and writing boxes, one instruction at a time. We have two workers, T1 and T2, and they share the same shelf.

WHY. Before we can talk about ordering writes, we need the picture of who writes and who reads. Everything else is layered on this.

PICTURE. In the figure, box data starts at 0 and box ready starts at false. T1 will eventually put a real value into data and then flip ready. T2 keeps re-reading ready, and only when it sees true does it read data.

Figure — Memory model — happens-before, acquire-release semantics

Step 2 — Program order is NOT the order the world sees

WHAT. T1's source code says: line (1) data = 42; then line (2) ready = true;. That top-to-bottom order in the code is called program order. But the value in data and the value in ready do not necessarily become visible to T2 in that same order.

WHY. Three separate machines are allowed to shuffle the timing of when a write is seen:

  1. the compiler (as-if rule) may emit the stores in a different order,
  2. the CPU runs out of order and holds writes in a store buffer,
  3. that store buffer drains to other cores whenever it likes.

Each of these is invisible inside one thread — T1 always reads back its own writes correctly. The shuffle only shows across threads.

PICTURE. The figure shows T1's tidy program order on the left, and the messy order T2 might actually observe on the right: ready can flip to true before data's 42 has arrived.

Figure — Memory model — happens-before, acquire-release semantics

Step 3 — The bug, drawn: T2 opens an empty box

WHAT. Suppose T2's loop reads ready == true and then reads data. Because Step 2 lets ready become visible first, T2 can read data while it is still 0 — the old value. The assert(data == 42) fires.

WHY. There is no rule connecting T1's write of data to T2's read of data. When two threads touch the same plain box, one writes, and nothing orders them, that is by definition a problem.

PICTURE. Red X: T2 sees the flag up but the box still holds 0. The dashed arrow (the connection we wish existed) is missing.

Figure — Memory model — happens-before, acquire-release semantics

Step 4 — Introduce the atomic box (still not enough)

WHAT. We upgrade ready from a plain bool into a std::atomic<bool>. An atomic box guarantees its own reads and writes are indivisible: T2 never sees a half-written ready.

WHY. We need a special box because ordinary boxes give no cross-thread promises at all. The atomic is the hook onto which we will later attach ordering. But note carefully: atomicity alone still does not order data. If we tag the operations relaxed, we only promise "no torn read of ready" — nothing about data.

PICTURE. The ready box now wears an "atomic" badge (no torn reads), but the dashed wish-arrow to data is still missing under relaxed.

Figure — Memory model — happens-before, acquire-release semantics

Term by term: store writes the box; the value true is what lands in it; relaxed is the promise level — here, no promise about data.


Step 5 — The release tag: a one-way floor under the store

WHAT. Change T1's store to std::memory_order_release. This adds a rule to the store: no memory operation that appears before it in program order may be moved to after it. The write data = 42 is now pinned below nothing and above the release — it cannot slip past.

WHY. We want T1 to finish writing data before the flag can possibly become visible. release is exactly the tool that says "everything I did earlier stays earlier." It is a downward barrier (a floor): earlier work cannot fall through it.

PICTURE. A solid orange floor sits under the ready.store. The data = 42 arrow bounces off it — it can never go below. This is the "publish the box, then raise the flag" order, now enforced.

Figure — Memory model — happens-before, acquire-release semantics


Step 6 — The acquire tag: a one-way ceiling over the load

WHAT. Change T2's load to std::memory_order_acquire. This adds a rule to the load: no memory operation that appears after it in program order may be moved to before it. So T2's read of data is pinned below the acquire-load — it cannot happen until after T2 has read ready.

WHY. We want T2 to read the flag first, and only then read data. acquire says "once I've seen the signal, everything I do afterwards stays afterwards." It is an upward barrier (a ceiling): later work cannot rise above it.

PICTURE. A solid magenta ceiling sits over the ready.load. T2's read data arrow bounces off it from below — it can never rise above the load.

Figure — Memory model — happens-before, acquire-release semantics


Step 7 — The handshake: floor + ceiling snap together

WHAT. The magic happens only when T2's acquire-load reads the exact value T1's release-store wrote (true). At that instant a cross-thread link forms — the release synchronizes-with the acquire.

WHY. Synchronization is not about clock time; it is about data flow. The value true had to travel from T1's box into T2's register. That journey is the handshake. If T2 read some other value (an older false), no journey happened, so no link forms. The parent note called this "must read the stored value".

PICTURE. The orange floor of the release and the magenta ceiling of the acquire click together into one continuous rail. The bounced-off data = 42 (below the floor) and read data (above the ceiling) are now on opposite ends of one rigid connection.

Figure — Memory model — happens-before, acquire-release semantics

Step 8 — Chaining into happens-before (the guarantee)

WHAT. We now assemble three links into one guarantee. Label the four events:

  • (1) data = 42
  • (2) ready.store(true, release)
  • (3) ready.load(acquire) == true
  • (4) read data

The chain:

Reading the symbols: is sequenced-before (order inside one thread); is synchronizes-with (the cross-thread handshake).

WHY. Happens-before is defined as the transitive closure: if you can walk from A to B along sb and sw arrows, then A happens-before B. Following the arrows: , so

Therefore T2's read data is guaranteed to see 42. And because (1) and (4) are now ordered, they are no longer a data race — the UB is gone.

PICTURE. Four nodes, three colored arrows spelling the chain; a big green check on the final assert(data == 42).

Figure — Memory model — happens-before, acquire-release semantics

Step 9 — Edge & degenerate cases (so no scenario surprises you)

WHAT / WHY, case by case:

  • Case A — relaxed store or load. No floor or ceiling, no synchronizes-with. The chain in Step 8 breaks at the sw link. (1) does not happen-before (4): the assert may fail. (This is Step 4's picture, still broken.)
  • Case B — acquire reads the old false. The loop condition !ready is still true, so T2 keeps looping; it has not yet read the stored value, so no handshake yet — exactly as the definition demands. The guarantee only arrives on the iteration that reads true.
  • Case C — the store never happens (T1 crashes before (2)). T2 spins forever reading false. No false guarantee is ever made — you simply never enter the assert. Safe, if stuck.
  • Case D — two independent atomic flags, two readers (IRIW). Acquire-release orders each thread along its own chain but gives no single global order. Two readers can disagree on the order of two independent stores. To force one global order you need seq_cst — the strongest tag (or a fence).

PICTURE. A 2×2 grid: A (broken chain), B (still looping), C (spin forever), D (two readers disagree). Each cell shows why the guarantee holds or does not.

Figure — Memory model — happens-before, acquire-release semantics

The one-picture summary

Everything above compressed: T1 writes data, the release floor pins that write below the flip; the value true travels across (the handshake); the acquire ceiling pins T2's read of data above the load; the transitive chain makes (1) happen-before (4), and the box is guaranteed full.

Figure — Memory model — happens-before, acquire-release semantics
Recall Feynman: retell the whole walkthrough in plain words

Ravi and Maya share a shelf of boxes. Ravi writes a secret into the data box, then raises a flag in the ready box. Normally the world is allowed to shuffle when Maya sees these two events, so she might see the flag up while the secret box is still empty — that's the bug (a data race, undefined behavior).

To fix it, Ravi raises the flag with a release: a rule that says "my earlier writing can't slip below this flag-raise" — a floor. Maya reads the flag with an acquire: "once I've seen the flag, my later reading can't slip above it" — a ceiling. The floor and ceiling only snap together if Maya reads the exact flag Ravi raised — that's the handshake, and it's about the value travelling from him to her, not about the clock.

Once they snap, Ravi's writing → his flag → her seeing the flag → her reading form one unbroken chain (happens-before), so Maya is guaranteed the secret box holds 42. If either of them used relaxed, there's no floor/ceiling, no handshake, and Maya can open an empty box. And this handshake orders things only along their own chain — a third friend watching two different flags might see events in a different order, which is why global agreement needs the stronger seq_cst.


Flashcards

Which single condition makes a release-store synchronize-with an acquire-load?
The acquire-load must read the value the release-store wrote (or a later value in its release sequence) — data must travel writer→reader.
In the message-passing pattern, which relation makes data == 42 guaranteed?
The happens-before chain (1)→sb→(2)→sw→(3)→sb→(4); since (1) hb (4), the read sees the write.
Is release a floor or a ceiling, and which ops does it block?
A floor (downward barrier): operations sequenced-before it cannot move below it.
Is acquire a floor or a ceiling, and which ops does it block?
A ceiling (upward barrier): operations sequenced-after it cannot move above it.
Why can acquire-release still let two readers disagree on order (IRIW)?
It orders memory only along each synchronizes-with chain, not a single global total order; that requires seq_cst.