JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Acquire, Release, and Happens-Before

Last guide handed you the memory_order names. This one turns two of them into a working handshake: a release store and an acquire load, which between them build the single most important relation in the whole memory model — happens-before, the rule that decides what a thread is actually allowed to see.

From a list of tags to one real question

The previous guide gave you the memory_order family as a menu you can hand to an atomic load or store: seq_cst, acquire, release, acq_rel, relaxed, consume. That menu is honest but unsatisfying, because a name like memory_order_acquire does not, by itself, tell you what your program will do. The sharper question hiding underneath every one of those tags is this: when thread A prepares some data and then thread B goes to read it, is B guaranteed to see A's write — and to see everything A did before that write? This whole guide exists to answer exactly that, and the answer has a name.

The name is happens-before. It is a relation between two memory operations, possibly in different threads, and it is the only thing the standard lets you use to argue "B is guaranteed to see what A wrote." If operation A happens-before operation B, then B sees A's effect; if there is no happens-before edge between a write and a read of the same plain location, you do not have a guarantee at all — you have a data race, and from the last two guides you already know that means undefined behavior, not just a stale value. So the practical skill of this entire rung reduces to one move: building happens-before edges where you need them, and spending no more ordering than each edge costs.

Notice what happens-before is not. It is not wall-clock time. Thread A's write can finish nanoseconds before thread B's read on a faster core, yet if no synchronization connects them, B is not guaranteed to see it — "earlier in real time" buys you nothing across threads. Happens-before is a relation the program constructs deliberately, out of two ingredients: the ordinary top-to-bottom order within a single thread (called sequenced-before), and the cross-thread links you build with atomics. Acquire and release are how you build the second ingredient.

Publish and subscribe: the two halves of the handshake

Here is the mechanism, stripped to its bones. A store tagged memory_order_release is a publish: it promises that every memory write the thread did before this store stays before it, and will become visible to anyone who later picks up this store's value. A load tagged memory_order_acquire is a subscribe: it promises that every memory read the thread does after this load stays after it. Each tag is a one-way fence — release pushes earlier writes down to the store, acquire pulls later reads down below the load — and neither does anything on its own. The magic happens only when they meet on the same variable.

When an acquire load reads the exact value that a release store wrote, the standard says the release store synchronizes-with the acquire load. That is the one cross-thread edge you are allowed to create. Chain it together: A's earlier writes are sequenced-before A's release store; A's release store synchronizes-with B's acquire load; B's acquire load is sequenced-before B's later reads. Glue those three links end to end and you get exactly what you wanted — A's earlier writes happen-before B's later reads, so B is guaranteed to see them. Happens-before is built, by hand, out of one synchronizes-with edge sandwiched between two slices of single-thread program order.

shared:  int data;   atomic_int ready = 0;

Thread A (producer)                 Thread B (consumer)
  data = 42;                          while (
  // ^ plain write, sequenced-before    atomic_load_explicit(
  atomic_store_explicit(                 &ready, memory_order_acquire) == 0)
    &ready, 1, memory_order_release);    ;        // spin until published
  //  ^ the publish                    int x = data;   // guaranteed to see 42

  release store  --synchronizes-with-->  the acquire load that reads its value
  => data = 42 (before release) happens-before x = data (after acquire)
The canonical message-passing pattern. The plain write to data is safe to read in B only because the release/acquire pair on ready builds a happens-before edge across the two threads. Read 'ready == 1' and you are promised data == 42.

Walking the message-passing example by hand

Let us prove the code block is correct without any hand-waving, because the whole point of a memory model is that you can. Thread A writes data = 42, an ordinary non-atomic write, then performs a release store of 1 into ready. Thread B spins on an acquire load of ready, and the instant it reads 1 — the value A stored — it stops and reads data. The claim is that B is guaranteed to read 42, never the uninitialized garbage data held before A touched it. Follow the three links: data = 42 is sequenced-before the release store (same thread, earlier line); the release store synchronizes-with B's acquire load (B read the value the release wrote); the acquire load is sequenced-before int x = data (same thread, later line).

Happens-before is transitive, so the three links compose into one: data = 42 happens-before int x = data. Because that edge exists, the write and the read are no longer a data race, and B must observe 42. Now break the spell to see how load-bearing the tags are: if you weaken either side to memory_order_relaxed, the synchronizes-with edge evaporates. B can read ready == 1 and still read data as garbage, because nothing now orders A's plain write before B's plain read. The atomic on ready stayed atomic — no torn value — but atomicity was never the issue here. The ordering was, and only the acquire/release tags supplied it.

Acquire/release versus seq_cst: a one-way bridge, not a global order

It is tempting to think acquire/release is just a cheaper seq_cst, but the difference is real and worth holding precisely. seq_cst gives a single total order that every thread agrees on: line up every seq_cst operation in the whole program into one global sequence, and all threads see the same sequence. Acquire/release gives no such global order. It builds only pairwise bridges — this release to that acquire — and the bridges are one-directional, like a publish flowing to a subscribe. Two acquire/release synchronizations on unrelated variables need not be seen in the same order by every onlooker.

That gap is what makes acquire/release cheaper and trickier at once. Cheaper, because on weakly-ordered hardware a release store often needs only a lightweight one-way barrier rather than the full barrier seq_cst demands — guide 5 shows this concretely on ARM, where seq_cst can cost a heavier fence than a release. Trickier, because the famous puzzle where it differs from seq_cst is exactly the store-buffering shape from guide 1: two threads each releasing one variable and acquiring the other can both observe the other's old value, an outcome seq_cst forbids but acquire/release allows. If your algorithm's correctness depends on a single agreed-upon order across multiple independent locations, acquire/release is not enough and you need seq_cst.

Where consume hides, and why a mutex is just this handshake

One family member deserves an honest word: memory_order_consume. On paper it is a weaker, cheaper acquire that orders only the reads that depend on the loaded value — chasing a published pointer, say — rather than every later read. In a perfect world that maps onto hardware that tracks data dependencies for free, especially ARM, and costs nothing at all. In practice every major compiler has, for years, quietly promoted consume to a full acquire because specifying and preserving dependency chains across optimizations turned out to be genuinely hard. So the honest advice is: understand what consume was meant to express, but write acquire. This is a real, current limitation of the standard, not a footnote you are free to ignore.

Now the payoff that ties this rung back to the synchronization rung you climbed earlier. Remember taking on faith that a mutex "just works" — that everything you write inside a locked critical section is safely visible to the next thread that locks? You can now see why, with no magic left. Unlocking a mutex performs a release; locking it performs an acquire. The unlock in thread A synchronizes-with the next lock in thread B, exactly as our release store synchronized-with our acquire load. Every write A made under the lock is sequenced-before A's unlock, which happens-before B's lock, which is sequenced-before B's reads. The critical section's visibility is the message-passing pattern, wearing a friendlier name.

And this is the deeper reason the data-race-free guarantee from guide 1 holds together. The guarantee promised that a race-free program behaves as if sequentially consistent. "Race-free" precisely means: every pair of conflicting accesses is ordered by happens-before. Acquire/release — whether you write it directly or get it bundled inside a mutex — is the machinery that manufactures those happens-before edges. So the entire discipline of safe shared memory comes down to one habit: for every place where one thread's write must be seen by another's read, build a happens-before edge across them, and let everything else stay as fast and unordered as the hardware likes.