Worked examples — Memory model — happens-before, acquire-release semantics
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.

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.
- List the events. (1)
data = 42, (2)ready.store(...,release), (3)ready.load(...,acquire)returnstrue, (4)assert(data==42). Why this step? You cannot reason about ordering until you name each evaluation. - Intra-thread edges. (1) sequenced-before (2); (3) sequenced-before (4). Why this step? Program order gives these for free within one thread.
- Cross-thread edge. The store (2) is a release; the load (3) is an acquire and it read the exact value
truethat (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. - 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.
- 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?
- Check for a synchronizes-with edge.
relaxedprovides 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. - 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.
- Consequence for
data.datais 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.)

Example 3 — acquire reads the wrong value (Cell C3)
Forecast: does which value we read matter, or is any acquire enough?
- Identify the release that wrote 2. The value
2came from the secondstore(2, release), which is sequenced-afterdata = 7. Why this step? The handshake attaches to the store that produced the value we read. - Build the edge. Acquire read
2; that value came from a release store; anddata = 7is sequenced-before that store. So hb chain:data=7→hbstore(2)→hbload()==2→hbassert. Why this step? Same C1 logic, just anchored to the store of value2. - Edge case — reading
0. If the acquire load returns0(initial value, no release wrote it), theifis false, no assert runs. If it read1,data=7is still ordered before store(1) too. Every value it can read that came from a release still ordersdata=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.)

Example 4 — the transitive chain across three threads (Cell C4)
Forecast: T3 never talked to T1 directly — can it still see T1's write?
- Edge 1 (T1→T2). T1's release of
asynchronizes-with T2's acquire ofa. Sox=1→hb everything after T2's acquire. Why this step? First handshake publishes T1's work to T2. - T2's local order.
x=1(inherited) →hb T2'sy=2→hb T2'sb.store(1,release). Why this step? Sequenced-before chains x's visibility forward inside T2. - Edge 2 (T2→T3). T2's release of
bsynchronizes-with T3's acquire ofb. Why this step? Second handshake publishes T2's release set, which now includes T1'sx=1. - Transitivity.
x=1→hby=2→hbb.store→hbb.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.)

Example 5 — an atomic counter with fetch_add (Cell C5)
Forecast: predict the final count first.
- Atomicity of RMW.
fetch_addis 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. - Count. 4 threads × 250 increments = . No lost updates.
Why this step? Atomic RMW guarantees each
+1is applied exactly once. - Why acq_rel (not relaxed)? If each increment also publishes data other threads read,
acq_relmakes each RMW simultaneously an acquire (see prior work) and a release (publish its own). If it's a pure counter with no payload,relaxedwould 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.)

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?
- acq_rel has no single global order. Acquire/release only order memory along a synchronizes-with chain.
xandyare independent atomics — there is no chain linking them. Why this step? The guarantee is chain-local, not global. - The bad outcome is legal. T3 can observe
xbeforeypropagates; T4 can observeybeforexpropagates. Resulta=1,b=0andc=1,d=0is 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). - Under seq_cst. All
seq_cstoperations share one global total order. Both readers must agree, soa=1,b=0andc=1,d=0cannot 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.)

Example 7 — half a handshake (Cell C7, degenerate)
Forecast: does one strong side rescue the pair?
- 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. - No edge. Release store + relaxed load ⇒ no synchronizes-with ⇒ no hb between
data=5and the assert. Why this step? Missing acquire = missing upward barrier in T2; T2's read ofdatamay hoist above the flag read. - 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?
- 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". - The subscribe. Any other thread reads
instancewith 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. - hb chain.
construct(w)→hbCAS release→hbload acquire→hbuse 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. - Failure branch. On CAS failure (
expectedwas overwritten with the winner's pointer via therelaxedfailure order), wedelete wand 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.)
Example 9 — exam twist: is this reordering legal? (Cell C9)
Forecast: which of these two moves does the release actually forbid?
- 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. - 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==1yet not seea. Forbidden. Why this step? This is the exact guarantee release exists to provide — it is the whole point of the tag. - 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.
- How to also pin (C). If you needed (C) held below (B) too, you would make (B)
seq_cstor insert anatomic_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?
- What consume promises. A
consumeload 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 whatacquiredoes). Why this step? You must know the narrower guarantee before trusting it. consume ⊂ acquire in reach. - Is
*pdependency-carrying?pcame directly from theconsumeload, and*preads throughp. So the dereference carries a dependency on the loaded pointer. Why this step? The guarantee only covers dependency-carrying reads; we must confirm*pqualifies. - Build the (dependency) chain.
payload=99→hbptr.store(release)→(dependency-ordered-before)→*p. Because*pdepends 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. - Where consume would FAIL. An unrelated read
assert(payload == 99)not reached throughpcarries no dependency, so consume does not order it — withacquireit 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?
- 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.
- Case A conclusion.
data = 42is sequenced-before the load, which is sequenced-before the assert. hb holds via sequenced-before alone —relaxedis irrelevant here. Assert holds. Why this step? Shows the tag choice does not matter when there is no cross-thread visibility question. - 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()returns7. Why this step? Evenrelaxedguarantees modification-order consistency for a single location; a thread never reads behind its own store. - 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?
- 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.
- The release fence.
data = 77is sequenced-before the release fence, which is sequenced-before the relaxed store. The fence stopsdata=77sinking past the store. Why this step? The fence supplies the downward barrier the relaxed store lacks. - The acquire fence. The relaxed load reads
true; the acquire fence (sequenced-after the load) stops the read ofdatahoisting above it. Why this step? The fence supplies the upward barrier the relaxed load lacks. - 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).