Where guide 3 left us, and what is still missing
By the end of guide 3 you had a working tool: a release store paired with an acquire load. Thread A writes some ordinary data, then does a release store to a flag; thread B does an acquire load of that flag, sees the value A wrote, and is now guaranteed to see the data too. The magic word for why this works is happens-before: the release store synchronizes-with the acquire load that reads it, and that one link stitches A's earlier writes into B's later reads. Everything hung on a single named variable — the flag — carrying the ordering on its back.
Two awkward situations break that comfortable picture. First: sometimes you want the ordering without wanting to bolt acquire or release semantics onto a particular access — for instance you have several relaxed atomics and you want one place that says "everything above me stays above, everything below me stays below", independent of which variable. That is a fence, also called a barrier. Second: sometimes a load and a store must happen as one indivisible action — read the old value, compute a new one, write it back — with no other thread allowed to slip in between. A plain acquire load followed by a release store cannot promise that; you need a read-modify-write. This guide builds both, and shows how the memory_order family you already know reattaches to each.
A fence: ordering with no variable attached
A fence is a standalone instruction — atomic_thread_fence() in C, std::atomic_thread_fence() in C++ — that takes a memory_order but touches no memory. Think of it as a line drawn across a single thread's instruction stream that the compiler and the CPU are forbidden to carry certain operations across. A release fence (memory_order_release) says: no read or write written before this line may be reordered to after it. An acquire fence (memory_order_acquire) says the mirror image: no read or write written after this line may move to before it. It is the same one-way-door image from guide 3 — release keeps things from sinking down, acquire keeps things from floating up — except the door is no longer attached to a particular variable's store or load.
How does that create synchronizes-with across threads, if the fence names no variable? The rule is slightly more elaborate than the plain acquire/release case, and it is worth stating exactly so you do not over-trust it. A release fence in thread A followed by a relaxed atomic store synchronizes-with an acquire fence in thread B that is preceded by a relaxed atomic load — provided that load reads the value the store wrote. In other words, the fences supply the ordering and a relaxed atomic on each side supplies the connection point. The variable is still there carrying the link between threads; the fence just lets you place the ordering wall somewhere other than right on the flag store itself.
Thread A (producer) Thread B (consumer)
------------------- -------------------
data = 42; while (flag.load(relaxed) == 0)
atomic_thread_fence(release); ;
flag.store(1, relaxed); atomic_thread_fence(acquire);
use(data); // sees 42
The release fence keeps data = 42 above the flag store.
The acquire fence keeps use(data) below the flag load.
Because B's load reads A's store, the two fences
synchronize-with, so data = 42 happens-before use(data).Compiler reordering vs CPU reordering: two walls, one fence
It helps to see that a fence has to stop two independent sources of reordering, because beginners often patch one and stay buggy. The first is the compiler: under the as-if rule the optimizer may freely move, merge, or delete memory accesses as long as a single thread cannot tell — and it has no idea another thread is watching. The second is the CPU: even after the compiler emits instructions in a fixed order, the hardware may execute and, crucially, make their effects visible in a different order, mostly because a write sits in a store buffer before other cores can see it. A correct fence must defeat whichever of these is in play.
This is the cleanest way to understand why the old trick of marking a shared variable volatile is not enough for threads. The volatile qualifier tells the compiler not to cache the value in a register and not to elide the access — that is a real but narrow guarantee. It says nothing to the CPU about visibility ordering, and it creates no happens-before edge between threads at all. So volatile can stop the compiler from optimizing away a poll loop, yet two threads sharing volatile data still race. An atomic with the right memory_order, or a fence, addresses both walls; volatile addresses only half of one. Use volatile for memory-mapped device registers, not for inter-thread communication.
Read-modify-write: a load and a store welded into one
Now the second missing piece. Picture two threads both running counter++ on a shared atomic. That single line is really three steps — load the value, add one, store it back — and if thread B's load lands between A's load and A's store, both threads read the same old number, both add one, both store the same result, and one increment is silently lost. This is the read-modify-write hazard from the concurrency rung, and no amount of acquire/release on separate load and store operations fixes it, because the danger is precisely the gap between them. What you need is an operation the hardware performs as a single indivisible unit: a read-modify-write, or RMW.
The C and C++ libraries give you a family of these: fetch_add(), fetch_sub(), fetch_or(), exchange() (store a new value and return the old), and the cornerstone compare-and-swap, spelled compare_exchange_weak() / compare_exchange_strong(). Each is guaranteed atomic: between its internal load and store, no other thread can observe or modify the location. Crucially, every RMW also takes a memory_order, so you reattach the ordering you learned in guide 3 directly onto the indivisible step. An RMW with memory_order_acq_rel behaves as an acquire on its read half and a release on its write half at once — perfect for a lock or a hand-off where you both publish your own work and observe someone else's.
- compare_exchange_strong(expected, desired) atomically reads the current value into a comparison; this read-side can carry acquire ordering.
- If the current value equals expected, it writes desired and returns true — the store-side can carry release ordering, publishing whatever you prepared.
- If the current value does not equal expected, it writes the actual current value back into expected and returns false, so your retry loop already holds the fresh value.
- You loop: recompute desired from the updated expected and try again. This compare-and-swap retry loop is the skeleton of almost every lock-free algorithm.
weak vs strong, and the seq_cst RMW that ties everything together
Two practical wrinkles finish the picture. First, why two spellings of compare-and-swap? compare_exchange_weak() is allowed to fail spuriously — to return false even when the value did equal expected — because on weakly-ordered hardware like ARM the natural implementation is a load-linked / store-conditional pair that can lose its reservation to an unrelated cache event. That spurious failure is harmless inside a retry loop, so the weak form is preferred in loops and often compiles to tighter code. The strong form never fails spuriously, paying for that with a hidden inner loop on those machines; use it when you are not already looping. Honest caveat: get this backwards and you write a correct-but-slower program, not a broken one — but knowing why the choice exists is the difference between cargo-culting and understanding.
Second, fences and RMWs both connect back to the strongest order from guide 2: memory_order_seq_cst. A seq_cst fence is stronger than a separate acquire-and-release fence — it additionally takes part in the single global total order that all seq_cst operations agree on, which can matter for the notorious store-load reordering case that acquire/release alone does not cover. Likewise, an RMW done with seq_cst slots into that same global order. The honest mental model is a ladder: relaxed gives you atomicity only; acquire/release gives you per-variable happens-before; seq_cst gives you one agreed-upon global ordering for the operations tagged with it — at the highest cost, since on real hardware it is the one that most often forces an actual barrier instruction.