5.2.27 · D3C++ Programming

Worked examples — Memory model — happens-before, acquire-release semantics

4,159 words19 min readBack to topic

This page is the exercise dojo for the Memory model — happens-before, acquire-release semantics. We will not re-teach the theory — we will stress-test it. Every worked example below is tagged with a cell from the scenario matrix, so you can see we left no case uncovered.


The scenario matrix

Cell Axis being tested What can go wrong Example
C1 release + acquire, load reads the value nothing — must hold Ex 1
C2 relaxed store, load reads value no hb edge → stale data Ex 2
C3 acquire load reads the wrong (older) value handshake never fires Ex 3
C4 transitive chain A→B→C across 3 threads hb is transitive Ex 4
C5 RMW fetch_add with acq_rel (a counter) ordering + atomicity together Ex 5
C6 acq_rel vs seq_cst — the IRIW / two-flag case no global order under acq_rel Ex 6
C7 degenerate: mixed order (release store, relaxed load) half a handshake = no edge Ex 7
C8 real-world word problem (double-checked init) correct lock-free singleton Ex 8
C9 exam twist: reorder legally, does assert still hold? reason about allowed reorderings Ex 9
C10 consume + dependency-ordered-before tricky data-dependency ordering Ex 10
C11 degenerate: zero threads racing (single thread) + self-store when the model gives ordering for free Ex 11
C12 standalone seq_cst fence + plain seq_cst store/load fence as a movable barrier Ex 12

Prerequisite reading if a cell confuses you: std-atomic and atomic operations, Sequential consistency vs weak memory models, Out-of-order execution and store buffers (CPU), std-atomic_thread_fence.

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

Example 1 — the handshake that must succeed (Cell C1)

Forecast: guess yes/no before reading on — and name the one condition that makes it hold.

  1. List the events. (1) data = 42, (2) ready.store(...,release), (3) ready.load(...,acquire) returns true, (4) assert(data==42). Why this step? You cannot reason about ordering until you name each evaluation.
  2. Intra-thread edges. (1) sequenced-before (2); (3) sequenced-before (4). Why this step? Program order gives these for free within one thread.
  3. Cross-thread edge. The store (2) is a release; the load (3) is an acquire and it read the exact value true that (2) wrote. So (2) synchronizes-with (3). Why this step? This is the only way to cross thread boundaries — and it requires the value handshake.
  4. Transitive closure. (1) →hb (2) →hb (3) →hb (4). Therefore (1) happens-before (4). Why this step? Happens-before is transitive, so the chain links the write to the read.
  5. Apply the visibility guarantee. hb ⇒ (4) sees all side-effects sequenced-before (1)+(1) itself, so data == 42. Why this step? This is the payoff — the visibility guarantee is exactly what an hb edge buys us.

Verify: The edge exists iff the acquire read the released value. Since the while loop only exits when it reads true, condition satisfied. Assert holds — always. ✓ (see figure s01, the green arrow is the synchronizes-with edge).


Example 2 — relaxed store poisons the data (Cell C2)

Forecast: does making ready atomic already protect data?

  1. Check for a synchronizes-with edge. relaxed provides atomicity + modification-order consistency only — no acquire/release. So (2) does not synchronize-with (3). Why this step? Without release/acquire tags, there is no cross-thread edge to build.
  2. Look for any other edge. (1)→(2) and (3)→(4) are intra-thread only; there is no chain across threads. Why this step? No edge ⇒ no happens-before ⇒ no guarantee.
  3. Consequence for data. data is non-atomic, both threads touch it, at least one writes, and there is no hb ordering → this is a data race = UB (undefined behavior). Why this step? The definition of a data race has exactly these three ingredients, and this is where we cash out the missing edge into a formal verdict.

Verify: Even on x86 (a strong model) the compiler may reorder data = 42 after the relaxed store, or T2 may read a cached stale data. The assert may fire — and formally the whole program is UB. ✗ Not safe. (See figure s03: same picture as s01 but the green edge is erased because relaxed builds no synchronizes-with.)

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

Example 3 — acquire reads the wrong value (Cell C3)

Forecast: does which value we read matter, or is any acquire enough?

  1. Identify the release that wrote 2. The value 2 came from the second store(2, release), which is sequenced-after data = 7. Why this step? The handshake attaches to the store that produced the value we read.
  2. Build the edge. Acquire read 2; that value came from a release store; and data = 7 is sequenced-before that store. So hb chain: data=7 →hb store(2) →hb load()==2 →hb assert. Why this step? Same C1 logic, just anchored to the store of value 2.
  3. Edge case — reading 0. If the acquire load returns 0 (initial value, no release wrote it), the if is false, no assert runs. If it read 1, data=7 is still ordered before store(1) too. Every value it can read that came from a release still orders data=7. Why this step? We must cover every value the load can legally return, not just the happy path.

Verify: For any observed value in {1, 2} the write data = 7 happens-before the assert; for 0 the assert is skipped. So the assert never fires. ✓ (figure s04: a timeline of the two release-stores and which value the acquire latches onto.)

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

Example 4 — the transitive chain across three threads (Cell C4)

Forecast: T3 never talked to T1 directly — can it still see T1's write?

  1. Edge 1 (T1→T2). T1's release of a synchronizes-with T2's acquire of a. So x=1 →hb everything after T2's acquire. Why this step? First handshake publishes T1's work to T2.
  2. T2's local order. x=1 (inherited) →hb T2's y=2 →hb T2's b.store(1,release). Why this step? Sequenced-before chains x's visibility forward inside T2.
  3. Edge 2 (T2→T3). T2's release of b synchronizes-with T3's acquire of b. Why this step? Second handshake publishes T2's release set, which now includes T1's x=1.
  4. Transitivity. x=1 →hb y=2 →hb b.store →hb b.load →hb assert. So T3 sees both. Why this step? Happens-before is a transitive closure, so chaining the two handshakes carries T1's write all the way to T3.

Verify: happens-before is a transitive closure — the visibility of x=1 rides through T2's release even though T3 never synchronized with T1 directly. Assert holds. ✓ (figure s02 draws the two arrows joining into one chain.)

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

Example 5 — an atomic counter with fetch_add (Cell C5)

Forecast: predict the final count first.

  1. Atomicity of RMW. fetch_add is a read-modify-write (RMW): it reads, adds, writes as one indivisible step, so no increment is lost. Why this step? Correct count needs atomicity, not ordering.
  2. Count. 4 threads × 250 increments = . No lost updates. Why this step? Atomic RMW guarantees each +1 is applied exactly once.
  3. Why acq_rel (not relaxed)? If each increment also publishes data other threads read, acq_rel makes each RMW simultaneously an acquire (see prior work) and a release (publish its own). If it's a pure counter with no payload, relaxed would suffice and be faster. Why this step? Choose the weakest tag that still gives the ordering you actually use.

Verify: Final counter == 1000. The RMW's modification order is a single total order per atomic, so the sum is exact regardless of interleaving. ✓ (figure s05: four threads funnelling +1 operations into one modification order.)

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

Example 6 — two flags, two readers: acq_rel is NOT global (Cell C6)

Forecast: can two observers disagree on the order of two independent writes?

  1. acq_rel has no single global order. Acquire/release only order memory along a synchronizes-with chain. x and y are independent atomics — there is no chain linking them. Why this step? The guarantee is chain-local, not global.
  2. The bad outcome is legal. T3 can observe x before y propagates; T4 can observe y before x propagates. Result a=1,b=0 and c=1,d=0 is allowed under acq_rel — the two readers disagree on the store order. Why this step? This is the defining IRIW inconsistency (see Sequential consistency vs weak memory models).
  3. Under seq_cst. All seq_cst operations share one global total order. Both readers must agree, so a=1,b=0 and c=1,d=0 cannot both occur. Why this step? seq_cst buys a global order with extra fences.

Verify: Contradiction test — if a global order existed, either x's store precedes y's or vice versa; T3 and T4 would agree. acq_rel provides no such order → disagreement possible (result: allowed). seq_cst → forbidden. ✓ (figure s06: two readers looking through two windows and seeing the flags rise in opposite orders.)

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

Example 7 — half a handshake (Cell C7, degenerate)

Forecast: does one strong side rescue the pair?

  1. Synchronizes-with needs BOTH sides. A synchronizes-with edge requires a release store and an acquire (or stronger) load that reads it. Here the load is relaxed. Why this step? The handshake is symmetric — a release with no acquire has no partner.
  2. No edge. Release store + relaxed load ⇒ no synchronizes-with ⇒ no hb between data=5 and the assert. Why this step? Missing acquire = missing upward barrier in T2; T2's read of data may hoist above the flag read.
  3. Consequence. Data race on data (non-atomic, unordered) → UB, assert may fire. Why this step? Same three-ingredient data-race verdict as Example 2, applied to the broken handshake.

Verify: Both endpoints must carry the ordering. release + relaxed = broken handshake. Not safe. ✗ (Symmetric failure: relaxed store + acquire load is equally broken.)


Example 8 — real-world: lock-free lazy singleton (Cell C8)

Forecast: could a reader see a non-null pointer but a half-built object?

  1. The publish. The winning thread constructs w (all writes), then does a compare-exchange (a RMW) with release success order. The construction is sequenced-before the release. Why this step? Release keeps the constructor writes below the store — the "downward fence".
  2. The subscribe. Any other thread reads instance with acquire. Reading the winner's pointer builds a synchronizes-with edge. Why this step? Acquire keeps later field accesses above the load — they see the constructor's writes.
  3. hb chain. construct(w) →hb CAS release →hb load acquire →hb use fields. Reader sees a complete object. Why this step? Transitivity from the writer's constructor to the reader's usage carries every constructor write across.
  4. Failure branch. On CAS failure (expected was overwritten with the winner's pointer via the relaxed failure order), we delete w and re-load(acquire) to get the winner's fully-published pointer. Why this step? The relaxed failure order is fine because the subsequent acquire load provides the real edge, so we never return an unpublished pointer.

Verify: Every returned pointer was published by a release CAS and read by an acquire load ⇒ hb from constructor to use ⇒ never a half-built Widget. ✓ (This is exactly the Lock-free programming patterns init idiom; a mutex would give the same edge more simply.)


Forecast: which of these two moves does the release actually forbid?

  1. What release forbids. release (B) is a downward fence: no memory op sequenced-before it may move below it. It says nothing about ops after it moving up. Why this step? Recall the mnemonic: "Release keeps the past below." We must know the barrier's direction before judging any move.
  2. Move (A) below (B)? (A) is sequenced-before the release (B). Sinking (A) below (B) would let an earlier write escape past the publish point, so a consumer's acquire would read b==1 yet not see a. Forbidden. Why this step? This is the exact guarantee release exists to provide — it is the whole point of the tag.
  3. Move (C) above (B)? (C) is sequenced-after the release. A release does not stop later ops from moving up above it. So hoisting (C) above (B) is legal (assuming (C) itself carries no ordering). Why this step? Release is one-directional (downward only); nothing pins the future in place.
  4. How to also pin (C). If you needed (C) held below (B) too, you would make (B) seq_cst or insert an atomic_thread_fence, turning the one-way gate into a two-way barrier. Why this step? Shows the reader the fix when the default asymmetry is not enough.

Verify: Release = downward barrier only. Move (A)-below-(B): illegal. Move (C)-above-(B): legal. This matches the parent's mnemonic "R↓ A↑". ✓ (See Compiler reordering and the as-if rule and std-atomic_thread_fence.)


Example 10 — memory_order_consume and data dependencies (Cell C10)

Forecast: does a weaker tag than acquire still protect data reached through the pointer we loaded?

  1. What consume promises. A consume load creates a dependency-ordered-before edge: only operations that carry a data dependency from the loaded value are ordered after the matching release — not all later operations (that is what acquire does). Why this step? You must know the narrower guarantee before trusting it. consume ⊂ acquire in reach.
  2. Is *p dependency-carrying? p came directly from the consume load, and *p reads through p. So the dereference carries a dependency on the loaded pointer. Why this step? The guarantee only covers dependency-carrying reads; we must confirm *p qualifies.
  3. Build the (dependency) chain. payload=99 →hb ptr.store(release) →(dependency-ordered-before)→ *p. Because *p depends on the loaded pointer, it sees the release's prior work. Why this step? Same publish/subscribe shape as acquire, but the edge only reaches dependency-linked reads.
  4. Where consume would FAIL. An unrelated read assert(payload == 99) not reached through p carries no dependency, so consume does not order it — with acquire it would. That is the trap. Why this step? Cover the case the weaker tag does not protect, so the reader is not surprised.

Verify: *p == 99 holds (dependency-carried). A sibling read of payload directly would not be ordered by consume. In practice most compilers promote consume to acquire; the standard even discourages its use — prefer acquire unless profiling demands otherwise. ✓


Example 11 — degenerate inputs: single thread and self-store (Cell C11)

Forecast: relaxed broke Example 2 — does it break these too?

  1. Case A — no second thread means no race. With a single thread, every pair of evaluations is connected by sequenced-before (program order). There is no other thread to disagree with. Why this step? A data race requires two threads; with one thread the "no ordering" precondition can never be met.
  2. Case A conclusion. data = 42 is sequenced-before the load, which is sequenced-before the assert. hb holds via sequenced-before alone — relaxed is irrelevant here. Assert holds. Why this step? Shows the tag choice does not matter when there is no cross-thread visibility question.
  3. Case B — reading your own most recent write. Within one thread, a load of an atomic must observe the latest value that thread stored to it (sequenced-before + modification-order consistency). So v.load() returns 7. Why this step? Even relaxed guarantees modification-order consistency for a single location; a thread never reads behind its own store.
  4. Contrast with Example 2. Example 2's danger was a second thread reading data. Remove the second thread (or read your own write) and the danger vanishes. Why this step? Pin down exactly which precondition changed.

Verify: Case A assert holds (single-thread sequenced-before). Case B assert holds (a thread reads its own latest store even under relaxed). Both safe, no UB. ✓


Example 12 — a standalone seq_cst fence (Cell C12)

Forecast: can a separate fence create the same edge that a release/acquire tag would?

  1. Fence-to-fence synchronization rule. A release fence before a relaxed store, paired with an acquire fence after a relaxed load that reads that store, creates a synchronizes-with edge — just like tagging the store/load directly. Why this step? This is the standalone-fence equivalent of Example 1; you must know the rule exists.
  2. The release fence. data = 77 is sequenced-before the release fence, which is sequenced-before the relaxed store. The fence stops data=77 sinking past the store. Why this step? The fence supplies the downward barrier the relaxed store lacks.
  3. The acquire fence. The relaxed load reads true; the acquire fence (sequenced-after the load) stops the read of data hoisting above it. Why this step? The fence supplies the upward barrier the relaxed load lacks.
  4. hb chain. data=77 →hb release-fence →hb (relaxed store read by relaxed load) →hb acquire-fence →hb assert. Edge complete. Why this step? Same message-passing chain as C1, only the barriers are detached into fences.

Verify: The two fences reconstruct exactly the C1 acquire-release edge, so data == 77 always holds. ✓ A plain seq_cst store/load pair (no separate fence) would give an even stronger guarantee — a global total order — but for this one-flag message pass the fence pair suffices. See std-atomic_thread_fence.


Recall Self-quiz on the matrix

Which cell is "release store + relaxed load"? ::: C7 — a broken half-handshake, no hb edge, UB. Can two acquire-loads disagree on the order of two independent release-stores? ::: Yes, under acq_rel (Cell C6, IRIW); no under seq_cst. Does an acquire-load that reads the initial value (not any release) build an edge? ::: No — it never observed a release, so no synchronizes-with (Cell C3 edge case). Final value of 4 threads × 250 fetch_add(1)? ::: 1000, guaranteed by atomic RMW (Cell C5). Does happens-before jump across a thread that only relayed the flag? ::: Yes — it is transitive (Cell C4). What does consume order that plain program order would not, and what does it MISS vs acquire? ::: It orders reads that carry a data dependency from the loaded value; it does NOT order unrelated (non-dependent) later reads that acquire would (Cell C10). Does a relaxed store/load break a single-threaded program? ::: No — one thread is fully ordered by sequenced-before; a race needs two threads (Cell C11). Can a standalone release fence + acquire fence replace tagged store/load? ::: Yes — the fence-to-fence rule builds the same synchronizes-with edge (Cell C12).