Acquire Release and Fence Semantics
Acquire and release are the two one-way memory-ordering operations that form the publish-subscribe backbone of nearly all lock-free Linux kernel code. A release store (
smp_store_release()) guarantees that all the CPU’s prior memory accesses become visible before the release store itself — it is a one-way barrier that nothing earlier can sink past. An acquire load (smp_load_acquire()) guarantees that all the CPU’s subsequent accesses happen after the acquire load — a one-way barrier nothing later can hoist above. When an acquire load reads the value written by a release store to the same variable, the two splice together into a happens-before edge: everything before the release on the producer is guaranteed visible to everything after the acquire on the consumer (tools/memory-model/Documentation/glossary.txt, v6.12). This release→acquire pairing is how the kernel safely hands a freshly-built data structure from one CPU to another, and it is the core ordering primitive underneath locks and RCU.
All kernel APIs in this note are read from Linux 6.12 LTS (a maintained long-term-support branch; mainline had moved into the 7.x series by the time of writing, 2026-09-04, so treat 6.12 as an LTS pin rather than as current mainline). Every macro definition quoted below was read from the v6.12 tree with curl, not from memory, and anything that could not be confirmed against code carries an uncertainty callout.
Scope: this note is the contract half of a pair
Memory ordering is best learned as two layers, and this vault splits them deliberately. Cache Coherence and the Store Buffer is the hardware half — it explains why reordering exists at all (store buffers, invalidate queues, MESI, x86-TSO versus weak ARM/POWER/RISC-V). This note is the contract half — it explains what you are promised when you write smp_store_release(), exactly where that promise stops, and which primitive to reach for in a given situation. If your question is “why is smp_wmb() free on x86 and a real instruction on ARM?”, read the sibling. If your question is “will smp_load_acquire() order this store against that load?”, you are in the right note. Neither note repeats the other; they cross-link.
Two further neighbours complete the picture. The Linux Kernel Memory Model is the formalisation — the cat files, herd7, and the axioms that make this contract machine-checkable; read it when you want the relations (ppo, hb, prop) rather than the API. Memory Barriers in the Linux Kernel is the catalogue of every barrier macro including the dma_*() and mandatory (mb()/rmb()/wmb()) families this note does not cover. Compiler Barriers and READ_ONCE WRITE_ONCE goes deeper on the compiler as an independent reordering engine; §“The Floor Beneath Everything” here covers only what is needed to make the acquire/release contract meaningful. Read-Copy-Update Fundamentals rests directly on this material — rcu_assign_pointer() is a release store — and Go Memory Model and The C Plus Plus Memory Model and Atomic Ordering express the same acquire/release ideas one layer up.
The Categories of Ordering
The LKMM groups memory-ordering operations into three top-level categories in decreasing strength (ordering.txt, v6.12):
- Barriers (fences) — order some or all of a CPU’s prior operations against some or all of its subsequent operations. The strongest is the full memory barrier
smp_mb(), which orders everything before against everything after, both ways. Weaker fences are directional:smp_wmb()orders prior stores against later stores;smp_rmb()orders prior loads against later loads. - Ordered memory accesses — operations that order themselves against prior or subsequent accesses: this is the acquire/release family, plus RCU read-side ordering and control dependencies. They are one-way.
- Unordered (relaxed) accesses —
READ_ONCE(),WRITE_ONCE(), non-value-returning RMWs likeatomic_inc(), and_relaxed()RMWs. They constrain the compiler but provide no hardware ordering except against accesses to the same variable.
Acquire and release sit in the middle: stronger and cheaper to reason about than bare fences-plus-relaxed-accesses, weaker (and far cheaper at runtime) than a full barrier. The whole design goal is to express exactly the ordering you need and no more, so the hardware can do the minimum work.
flowchart TB subgraph S1["1 · BARRIERS — order OTHER accesses around a point"] direction TB MB["smp_mb()<br/>everything before ↔ everything after<br/>both directions, all CPUs agree"] WMB["smp_wmb() — prior stores before later stores"] RMB["smp_rmb() — prior loads before later loads"] BAR["barrier() — compiler only, emits no instruction"] MB --> WMB --> RMB --> BAR end subgraph S2["2 · ORDERED ACCESSES — order THEMSELVES, one way"] direction TB REL["Release: smp_store_release, atomic_set_release,<br/>rcu_assign_pointer, *_release RMW, spin_unlock"] ACQ["Acquire: smp_load_acquire, atomic_read_acquire,<br/>*_acquire RMW, spin_lock"] RCUO["RCU read side: rcu_read_lock/unlock,<br/>rcu_dereference (address dependency only)"] CTRL["Control dependency: load → if → store<br/>(fragile — the compiler does not know it exists)"] REL --> ACQ --> RCUO --> CTRL end subgraph S3["3 · UNORDERED — atomic-ish, but no cross-variable order"] direction TB RO["READ_ONCE / WRITE_ONCE / atomic_read / atomic_set"] RLX["*_relaxed RMW, non-value-returning RMW<br/>(atomic_inc, atomic_dec, set_bit)"] PLAIN["plain C accesses — not even atomic;<br/>may be torn, invented, fused, or deleted"] RO --> RLX --> PLAIN end S1 -->|"decreasing strength"| S2 -->|"decreasing strength"| S3 NOTE["Every row above still provides SV:<br/>ordering of later accesses to the SAME variable.<br/>That is coherence, and it is free."] S3 -.-> NOTE
The LKMM’s three categories of ordering operation, in decreasing strength, with the members of each. What it shows: barriers act on other accesses in a neighbourhood; ordered accesses order themselves against one side only; unordered accesses order nothing across variables. The insight to take: this is a ladder you climb only as far as you must. Each rung upward costs real instructions on weakly-ordered hardware, and the entire art of lock-free kernel code is picking the lowest rung that still forbids the outcome you are afraid of. Note the box at the bottom: even the weakest rung gives you same-variable ordering (“SV” in the model’s cheat sheet), which is why CoRR/CoWW-style single-variable litmus tests never need a barrier — see Cache Coherence and the Store Buffer for why that one guarantee comes free from the coherence protocol.
The kernel ships this ladder as a machine-readable table. tools/memory-model/Documentation/cheatsheet.txt (v6.12) has one row per operation and one column per ordering question, and it is worth learning to read because it settles arguments quickly (cheatsheet.txt):
| Operation | Orders prior R | Orders prior W | Orders later R | Orders later W | Orders later RMW | Same-var (SV) |
|---|---|---|---|---|---|---|
Relaxed store (WRITE_ONCE) | — | — | — | — | — | Y |
Relaxed load (READ_ONCE) | — | — | — | — | — | Y |
rcu_dereference() | — | — | dependent only | dependent only | — | Y |
Successful *_acquire() | — | — | Y | Y | Y | Y |
Successful *_release() | Y | Y | — | — | — | Y |
smp_rmb() | Y (loads) | — | Y (loads) | — | R part only | — |
smp_wmb() | — | Y (stores) | — | Y (stores) | W part only | — |
smp_mb() / synchronize_rcu() | Y | Y | Y | Y | Y | — |
| Successful full non-void RMW | Y | Y | Y | Y | Y | Y |
The v6.12 ordering cheat sheet, transcribed with its column abbreviations expanded. What it shows: the exact asymmetry that defines acquire and release — the acquire row has entries only in the “later” columns, the release row only in the “prior” columns. The insight to take: the two rows are mirror images, and neither has both halves. Only the smp_mb() and full-RMW rows are filled across, which is precisely the formal statement of “a release plus an acquire is not a full barrier”. The C and P flags the original table carries on the release and smp_mb() rows stand for cumulative and propagating — the subject of the A-cumulativity section below.
Mental Model: One-Way Permeable Barriers
flowchart TB subgraph PROD["Producer CPU"] direction TB PA["access A (plain or marked)"] PB["access B"] REL["smp_store_release(&flag, 1)<br/>— nothing above sinks below —"] PA --> PB --> REL end subgraph CONS["Consumer CPU"] direction TB ACQ["r = smp_load_acquire(&flag)<br/>— nothing below hoists above —"] CB["access C"] CD["access D"] ACQ --> CB --> CD end REL -- "rf: acquire reads<br/>value of release<br/>(same variable)" --> ACQ PA -. "happens-before<br/>(A,B all visible to C,D)" .-> CD
Acquire and release as one-way gates. What it shows: the release store is a floor — accesses A and B cannot move below it; the acquire load is a ceiling — accesses C and D cannot move above it. When the acquire reads-from the release (the dotted rf edge, requiring the same variable and the same value), the two one-way barriers compose into a full happens-before edge spanning the two CPUs. The insight to take: neither operation alone orders the two CPUs; it is the reads-from match on a shared variable that turns two local one-way constraints into a cross-CPU guarantee. A release with no matching acquire, or an acquire that reads a stale value, publishes nothing.
The directionality is precise (memory-barriers.txt items (5),(6), v6.12):
- An ACQUIRE “acts as a one-way permeable barrier. It guarantees that all memory operations after the ACQUIRE will appear to happen after the ACQUIRE… Memory operations that occur before an ACQUIRE may appear to happen after it completes.” So earlier accesses can leak downward through it; later ones cannot leak upward.
- A RELEASE “also acts as a one-way permeable barrier… all memory operations before the RELEASE will appear to happen before the RELEASE… Memory operations that occur after a RELEASE may appear to happen before it completes.” So later accesses can leak upward; earlier ones cannot leak downward.
This is why they pair so naturally: the release seals everything that came before it, the acquire opens a region after it, and the reads-from match connects the seal to the opening.
The word “permeable” is doing real work in that phrasing, and the consequence is usually the first thing that surprises people: things leak into a critical section, and that is legal. memory-barriers.txt spells the consequence out — “one of the consequences of lock ACQUIREs and RELEASEs being only one-way barriers is that the effects of instructions outside of a critical section may seep into the inside of the critical section.” It then gives the exact permitted reordering:
*A = a; may execute as:
ACQUIRE M ACQUIRE M
RELEASE M STORE *B
*B = b; STORE *A
RELEASE M
flowchart TB subgraph SRC["Program order as written"] direction TB A1["*A = a (before the ACQUIRE)"] A2["ACQUIRE M"] A3["RELEASE M"] A4["*B = b (after the RELEASE)"] A1 --> A2 --> A3 --> A4 end subgraph OBS["A legal execution seen by another CPU"] direction TB B1["ACQUIRE M"] B2["STORE *B<br/>(leaked upward through the RELEASE)"] B3["STORE *A<br/>(leaked downward through the ACQUIRE)"] B4["RELEASE M"] B1 --> B2 --> B3 --> B4 end SRC ==>|"CPU is free to produce"| OBS RULE1["ACQUIRE blocks upward motion only<br/>→ *A may sink INTO the section"] RULE2["RELEASE blocks downward motion only<br/>→ *B may rise INTO the section"] RULE3["Result: ACQUIRE-then-RELEASE is NOT a full barrier.<br/>Neither is RELEASE-then-ACQUIRE."] A2 -.-> RULE1 A3 -.-> RULE2 RULE1 --> RULE3 RULE2 --> RULE3
The one-way property, drawn as the reordering it permits, taken verbatim from memory-barriers.txt (v6.12). What it shows: two stores written outside an empty critical section can both end up inside it, and can even swap with each other on the way in. The insight to take: a critical section is a roach motel — accesses check in but they do not check out. This is the mechanical reason an ACQUIRE+RELEASE sequence cannot be used as a full barrier: *A sank past the acquire, *B rose past the release, and once both are inside they may cross freely. The kernel doc also notes the mirror case: *A = a; RELEASE M; ACQUIRE N; *B = b; may execute as ACQUIRE N, STORE *B, STORE *A, RELEASE M, which looks like it should deadlock but cannot — if a deadlock threatened, the release “would simply complete, thereby avoiding the deadlock”, because the CPU (unlike the compiler or the programmer) is only reordering execution, never program text.
That last point deserves emphasis because it is the one place where “the CPU may reorder it” does not imply “your code may be written that way.” memory-barriers.txt is explicit: “One key point is that we are only talking about the CPU doing the reordering, not the compiler. If the compiler (or, for that matter, the developer) switched the operations, deadlock could occur.” The hardware’s reordering is speculative and revocable; a source-level reordering is not.
The Worked Pairing: Message Passing with Release/Acquire
Memory ordering is taught with litmus tests, and there is no substitute for drawing them. A litmus test is a tiny multi-threaded program plus an exists clause naming an outcome; the model (or the hardware) answers Never, Sometimes, or Always. The kernel ships a corpus of them in tools/memory-model/litmus-tests/, runnable under the herd7 simulator, and each one is named after the class of pattern it belongs to — MP for message passing, SB for store buffering, LB for load buffering, and so on. The class names come from Sarkar and Sewell’s POWER and ARM Litmus Tests, which the kernel’s own litmus-tests/README (v6.12) cites as “the infamous test6.pdf”. Everything below is one of those tests, drawn as a two-column timeline.
First, the bug: MP with no ordering at all
Before seeing what release/acquire buys, look at what breaks without it. MP+poonceonces.litmus is the same publish-then-flag code with every ordering operation stripped out, leaving only the marked accesses (v6.12):
C MP+poonceonces
(* Result: Sometimes *)
{}
P0(int *buf, int *flag) // Producer
{
WRITE_ONCE(*buf, 1); // fill the buffer
WRITE_ONCE(*flag, 1); // raise the flag — but nothing seals the order
}
P1(int *buf, int *flag) // Consumer
{
int r0; int r1;
r0 = READ_ONCE(*flag); // see the flag...
r1 = READ_ONCE(*buf); // ...then read garbage
}
exists (1:r0=1 /\ 1:r1=0) (* Bad outcome — and it HAPPENS *)
flowchart TB subgraph P0["P0 — Producer (time flows down)"] direction TB W1["WRITE_ONCE(buf, 1)"] W2["WRITE_ONCE(flag, 1)"] W1 --> W2 end subgraph P1["P1 — Consumer (time flows down)"] direction TB R1["r0 = READ_ONCE(flag) → 1"] R2["r1 = READ_ONCE(buf) → 0 ⚠"] R1 --> R2 end W2 -. "rf: consumer reads<br/>the flag store" .-> R1 R2 -. "fr: the buf load happened<br/>BEFORE the buf store" .-> W1 X1["Two independent ways to get here:<br/>(a) P0's stores reach P1 out of order<br/>(b) P1 speculates the buf load before the flag load"] W2 -.-> X1 R1 -.-> X1 V["herd7 verdict: Sometimes<br/>(and on real ARM64 hardware, actually observed)"] X1 --> V
The broken message-passing pattern, MP+poonceonces. What it shows: the consumer sees flag == 1 yet reads buf == 0 — it observed the second store without the first. The dotted rf (reads-from) edge is the intended communication; the dotted fr (from-reads) edge is the anomaly, saying the consumer’s load of buf was ordered before the producer’s store to buf even though it came later in wall-clock intuition. The insight to take: there are two independent failures to fix, one on each CPU, and this is why barriers must be paired. The producer’s two stores can reach the consumer out of order (store buffer, store-to-store reordering); and the consumer’s two loads can execute out of order (load speculation, invalidate queue). Fixing only one side leaves the other bug live — memory-barriers.txt states this as a flat non-guarantee: “There is no guarantee that a CPU will see the correct order of effects from a second CPU’s accesses, even if the second CPU uses a memory barrier, unless the first CPU also uses a matching memory barrier.”
Then, the fix
The canonical use is the Message Passing (MP) pattern, here in MP+pooncerelease+poacquireonce.litmus (v6.12):
C MP+pooncerelease+poacquireonce
{}
P0(int *buf, int *flag) // Producer
{
WRITE_ONCE(*buf, 1); // fill the buffer (prior access)
smp_store_release(flag, 1); // publish: seals the buf write below it
}
P1(int *buf, int *flag) // Consumer
{
int r0;
int r1;
r0 = smp_load_acquire(flag); // subscribe: opens region after it
r1 = READ_ONCE(*buf); // read the buffer (subsequent access)
}
exists (1:r0=1 /\ 1:r1=0) (* Bad outcome. *)
The test’s comment is Result: Never. The reasoning, straight from the recipes file: “The smp_store_release() macro orders any prior accesses against the store, while the smp_load_acquire macro orders the load against any subsequent accesses. Therefore, if the final value of r0 is the value 1, the final value of r1 must also be the value 1” (recipes.txt §Release and acquire, v6.12). Step by step: the release orders WRITE_ONCE(*buf,1) before the store to flag; if P1’s acquire reads flag == 1 it read-from P0’s release, establishing that P0’s release happened before P1’s acquire; the acquire orders that load before P1’s READ_ONCE(*buf). Chaining: buf=1 → release → (rf) → acquire → buf read, so P1 must see buf == 1 and the exists clause is unreachable. The bug from the unordered MP variant (The Linux Kernel Memory Model) is gone.
flowchart TB subgraph P0["P0 — Producer"] direction TB W1["WRITE_ONCE(buf, 1)"] W2["smp_store_release(flag, 1)"] G1{{"RELEASE GATE<br/>nothing above may sink below"}} W1 --> G1 --> W2 end subgraph P1["P1 — Consumer"] direction TB R1["r0 = smp_load_acquire(flag) → 1"] G2{{"ACQUIRE GATE<br/>nothing below may hoist above"}} R2["r1 = READ_ONCE(buf) → must be 1"] R1 --> G2 --> R2 end W2 == "rf — SAME VARIABLE, and the acquire<br/>read exactly the released value" ==> R1 W1 -. "happens-before, spliced from<br/>two one-way gates + one rf edge" .-> R2 V["herd7 verdict: Never — the exists clause is unreachable"] R2 --> V
The same pattern with the release/acquire pair, MP+pooncerelease+poacquireonce. What it shows: each CPU now carries one gate, and the thick rf arrow is the splice that joins them. The release gate forbids exactly the producer-side reordering that broke the previous diagram; the acquire gate forbids exactly the consumer-side one. The insight to take: the pairing, not either primitive alone, is what creates the cross-CPU guarantee — and the pairing is conditional on the rf edge actually existing. If r0 comes back 0, the consumer did not read the release, no edge forms, and it is guaranteed nothing at all about buf. Compare with the previous figure: the code differs by two macro names, and the verdict flips from Sometimes to Never.
Compare the three solutions to MP, all Never. Each is a real litmus test in the v6.12 tree:
| Solution | Producer | Consumer | Litmus test | Why you might pick it |
|---|---|---|---|---|
| Directional fences | WRITE_ONCE; smp_wmb(); WRITE_ONCE | READ_ONCE; smp_rmb(); READ_ONCE | MP+fencewmbonceonce+fencermbonceonce | Legacy; still heavily used. One fence can cover several publications at once |
| Release / acquire | WRITE_ONCE; smp_store_release | smp_load_acquire; READ_ONCE | MP+pooncerelease+poacquireonce | Default choice. Self-documenting, names the shared variable, cheaper than smp_mb() |
| Assign / dereference | WRITE_ONCE; rcu_assign_pointer | rcu_dereference; READ_ONCE | MP+onceassign+derefonce | RCU’s read side. Cheapest of all — no barrier instruction on the reader — but orders only dependent accesses |
The three in-tree ways to make message passing safe. What it shows: the same Never verdict reached by three different mechanisms — explicit fences, self-ordering accesses, and dependency ordering. The insight to take: they are not interchangeable in strength. Reading down the table, each row orders less than the one above it while still forbidding the MP bad outcome, and each is correspondingly cheaper. Picking the right row is the whole skill.
The release/acquire form is preferred over the fence form because, as the docs put it, it “makes it easier to connect up the different pieces of the concurrent algorithm” — you find the publisher by searching for the smp_store_release() that writes the variable your smp_load_acquire() reads (ordering.txt §Release Operations). It is also strictly stronger than smp_rmb(): an acquire orders the load against subsequent stores as well as loads, whereas smp_rmb() only orders loads against loads. That extra strength is exactly what the S litmus test isolates. S+poonceonces.litmus (Sometimes) has P0 store x=2 then y=1, and P1 read y then store x=1; the bad outcome is x=2 at the end with P1 having seen y==1, meaning P1’s store to x was overwritten by P0’s earlier store — a store-to-store link that ran backwards. S+fencewmbonceonce+poacquireonce.litmus fixes it with smp_wmb() on the producer and smp_load_acquire() on the consumer, and is Never precisely because the acquire orders its load against a subsequent store. An smp_rmb() in the same place would not have helped.
An important cost note before moving on: the reason to reach for the weakest sufficient primitive is not aesthetics. ordering.txt works the arithmetic explicitly. If the producer’s prior access is a load rather than a store, smp_wmb() cannot order it and you would be forced up to smp_mb() — but smp_store_release() still works, and “smp_mb() often incurs much higher overhead than does smp_store_release(), which still provides the needed ordering”. On x86 the release version “might compile to a simple load instruction followed by a simple store instruction”, while the smp_mb() version compiles to a locked instruction. Same guarantee where it matters, an order of magnitude apart in cost.
Where Release and Acquire Come From
These are not just smp_store_release()/smp_load_acquire(). The full families (ordering.txt §§Release/Acquire Operations):
- Release operations:
smp_store_release(),atomic_set_release(),atomic_long_set_release(),rcu_assign_pointer(), and value-returning RMWs whose names end in_release(atomic_fetch_add_release(),cmpxchg_release()). Aspin_unlock()is also a release. For a compound RMW, release ordering applies only to the store portion. Conditional RMWs likecmpxchg_release()provide ordering only when they succeed. - Acquire operations:
smp_load_acquire(),atomic_read_acquire(),atomic64_read_acquire(), and_acquire-suffixed RMWs (atomic_xchg_acquire(),atomic_cmpxchg_acquire()). Aspin_lock()is an acquire. For a compound RMW, acquire ordering applies only to the load portion; again, conditional forms order only on success.
In the formal linux-kernel.cat, these become the acq-po and po-rel relations ([Acquire] ; po ; [M] and [M] ; po ; [Release]) that feed ppo and cumul-fence — i.e. they are preserved program order on one CPU and propagation-ordering cumulative fences across CPUs (linux-kernel.cat lines 28–29, 90, v6.12).
The compound-RMW split, drawn
The “only the load portion / only the store portion” rule is the one people misapply most, so it is worth a picture. A read-modify-write such as atomic_xchg() or cmpxchg() is internally two memory events glued into one atomic operation, and each ordering suffix attaches to a different one.
flowchart TB subgraph RMW["One compound RMW, e.g. cmpxchg(&v, old, new)"] direction TB L["LOAD half — reads the old value"] S["STORE half — writes the new value"] L --> S end subgraph V["What each suffix orders"] direction TB RLXV["_relaxed → NOTHING is ordered<br/>(the RMW is still atomic)"] ACQV["_acquire → the LOAD half is an acquire<br/>later accesses cannot hoist above it<br/>PRIOR accesses are NOT ordered"] RELV["_release → the STORE half is a release<br/>prior accesses cannot sink below it<br/>LATER accesses are NOT ordered"] FULLV["no suffix, value-returning → FULLY ORDERED<br/>equivalent to smp_mb() before AND after"] VOIDV["no suffix, void-returning (atomic_inc, set_bit)<br/>→ NOTHING is ordered"] end L -.-> ACQV S -.-> RELV RMW -.-> RLXV RMW -.-> FULLV RMW -.-> VOIDV COND["CONDITIONAL forms (cmpxchg, try_cmpxchg,<br/>test_and_set_bit): on FAILURE, unordered —<br/>whatever the suffix says"] FULLV --> COND ACQV --> COND RELV --> COND
How ordering suffixes attach to the two halves of a read-modify-write. What it shows: _acquire and _release are not “weaker versions of the whole operation” — they are annotations on different halves of it, so they order opposite sides and are not substitutable. The insight to take: three traps live in this picture. (1) atomic_xchg_acquire() gives you nothing about your prior stores, which is exactly what a publish needs — use _release there. (2) atomic_inc() and set_bit() order nothing at all, despite being atomic; atomicity and ordering are different properties. (3) A failed cmpxchg() is unordered even in the fully-ordered form. atomic_t.txt (v6.12) states the whole rule in four lines: “non-RMW operations are unordered; RMW operations that have no return value are unordered; RMW operations that have a return value are fully ordered; RMW operations that are conditional are unordered on FAILURE.”
The full-ordering case is worth stating precisely because it is stronger than people expect. ordering.txt says a fully ordered RMW “partition[s] the CPU’s accesses into three groups” — everything before, the RMW itself, everything after — and “all CPUs will agree that any operation in a given partition happened before any operation in a higher-numbered partition.” That is a genuine smp_mb() on both sides. atomic_t.txt confirms: atomic_fetch_add() is equivalent to smp_mb__before_atomic(); atomic_fetch_add_relaxed(); smp_mb__after_atomic(); (though it “might be implemented more efficiently”). The list of fully-ordered primitives therefore includes atomic_add_return(), atomic_dec_and_test(), cmpxchg(), and xchg() — plus, at vastly greater cost, RCU’s grace-period primitives synchronize_rcu() and friends.
Locks are acquire/release — and that is the honest recommendation
spin_lock() is an acquire and spin_unlock() is a release; the same holds for mutexes, semaphores, R/W spinlocks and R/W semaphores (memory-barriers.txt §Lock Acquisition Functions, v6.12). This is the most important practical fact in the whole note, and it points the other way from everything else in it: most kernel code should not be writing barriers at all. If your data is protected by a lock, the lock’s acquire and release already carry exactly the ordering this note describes, correctly paired, with lockdep checking the pairing for you. recipes.txt (v6.12) makes the same point in passing about the wakeup pattern — “note that use of locking can greatly simplify this pattern” — and the LKMM’s ordering.txt opens its unordered-access section by listing “guard all accesses to a given variable by a particular lock” as the first safe way to use plain C accesses. Reach for smp_store_release() when you are deliberately building something lock-free and can articulate which litmus test you are avoiding; otherwise take the lock.
Four consequences of lock-ordering follow directly from the one-way property, and memory-barriers.txt enumerates them:
- Accesses after the ACQUIRE complete after it; accesses before it may complete after it (they leak in).
- Accesses before the RELEASE complete before it; accesses after it may complete before it (they leak in).
- ACQUIRE-then-ACQUIRE: all prior acquires complete before a later acquire.
- A failed conditional acquire implies no barrier whatsoever.
spin_trylock()that returns false has ordered nothing.
There is one intriguing extra guarantee that is not a plain consequence of one-wayness. LB+unlocklockonceonce+poacquireonce.litmus (v6.12, Result: Never) establishes that “if two locked critical sections execute on the same CPU, all accesses in the first must execute before any accesses in the second, even if the critical sections are protected by different locks”, and MP+unlocklockonceonce+fencermbonceonce.litmus establishes the store-propagation counterpart. In the formal model this appears as the po-unlock-lock-po relation feeding ppo (linux-kernel.cat lines 30, 85). An unlock followed by a lock on the same CPU is therefore stronger than a bare release followed by a bare acquire — a rare case where the lock APIs give you more than their acquire/release labels suggest.
smp_mb__after_spinlock() and the RCpc/RCsc distinction
Acquire/release as defined so far is what the literature calls RCpc — release consistency, processor consistent: the chain orders the CPUs on it, but a CPU off the chain may disagree. Some kernel code (notably the scheduler) needs the stronger RCsc — sequentially consistent — form, where a lock acquisition also orders against everything globally. That upgrade is spelled smp_mb__after_spinlock(), and include/linux/spinlock.h (v6.12) documents exactly the two properties it buys:
/* Property 1: an SB pattern straddling the lock is forbidden */
/* CPU0 CPU1 */
/* WRITE_ONCE(X, 1); WRITE_ONCE(Y, 1); */
/* spin_lock(S); smp_mb(); */
/* smp_mb__after_spinlock(); r1 = READ_ONCE(X); */
/* r0 = READ_ONCE(Y); */
/* spin_unlock(S); */
/* forbidden: r0 == 0 && r1 == 0 */The header’s own comment concludes: “Property (2) upgrades the lock to an RCsc lock.” It also explains where the macro is free and where it is not: “since most load-store architectures implement ACQUIRE with an smp_mb() after the LL/SC loop, they need no further barriers. Similarly all our TSO architectures imply an smp_mb() for each atomic instruction and equally don’t need more. Architectures that can implement ACQUIRE better need to take care.” That last sentence is the interesting one, and reading the arch trees confirms it in both directions:
| Architecture | smp_mb__after_spinlock() in v6.12 | Why |
|---|---|---|
| x86 | no override → generic kcsan_mb(), which compiles to do { } while (0) unless CONFIG_KCSAN_WEAK_MEMORY | TSO: the LOCK-prefixed instruction in the lock acquisition is already a full barrier |
| arm64 | #define smp_mb__after_spinlock() smp_mb() in arch/arm64/include/asm/spinlock.h — a real DMB ISH | arm64 does “implement ACQUIRE better”, with LDAR/LDAXR, so it genuinely needs the upgrade |
| RISC-V | RISCV_FENCE(iorw, iorw) in arch/riscv/include/asm/barrier.h — a real full fence | same reason |
Where the RCpc→RCsc upgrade actually costs something. What it shows: the same source line is free on x86 and a real fence instruction on arm64 and RISC-V. The insight to take: the architectures that are best at cheap acquire semantics are exactly the ones that must pay to strengthen them — an inversion that catches people who benchmark scheduler paths on x86 and conclude the barrier is free everywhere. (include/linux/kcsan-checks.h, arch/arm64/include/asm/spinlock.h, v6.12.) The kernel’s real users are __schedule() and try_to_wake_up(), cited by name in the spinlock.h comment.
Contrast: The Full Fence smp_mb()
A full memory barrier smp_mb() orders all prior accesses against all subsequent accesses, in both directions, from the viewpoint of all CPUs (ordering.txt §Full Memory Barriers). This is strictly stronger than a release+acquire pair, and the difference is the Store Buffer (SB) pattern, which a release/acquire chain does not fix:
C SB+fencembonceonces
{}
P0(int *x, int *y) { WRITE_ONCE(*x, 1); smp_mb(); r0 = READ_ONCE(*y); }
P1(int *x, int *y) { WRITE_ONCE(*y, 1); smp_mb(); r0 = READ_ONCE(*x); }
exists (0:r0=0 /\ 1:r0=0)
SB is store-then-load on each CPU. Even x86-TSO allows both CPUs to read 0 (each store sits in its store buffer while the load reads the other’s stale value). Only a full barrier forbids it — the one reordering that a store-buffer hardware genuinely performs is sinking a store past a later load to a different address, and that is exactly what smp_mb() blocks and what acquire/release do not. Crucially: a RELEASE+ACQUIRE pair is not a full memory barrier (memory-barriers.txt, v6.12). If you have a store followed by a load that must not be reordered, neither a release nor an acquire helps — you need smp_mb(). This is the single most common acquire/release mistake.
flowchart TB subgraph P0["P0"] direction TB A1["WRITE_ONCE(x, 1)"] A2["r0 = READ_ONCE(y) → 0 ⚠"] A1 --> A2 end subgraph P1["P1"] direction TB B1["WRITE_ONCE(y, 1)"] B2["r0 = READ_ONCE(x) → 0 ⚠"] B1 --> B2 end A2 -. "fr — P0's load of y ran<br/>BEFORE P1's store to y" .-> B1 B2 -. "fr — P1's load of x ran<br/>BEFORE P0's store to x" .-> A1 WHY["Both links are read-to-write (fr).<br/>NEITHER is a reads-from link.<br/>Nothing was published, so there is<br/>nothing for an acquire to read."] A2 -.-> WHY B2 -.-> WHY FIX["Only cure: smp_mb() between the store<br/>and the load on BOTH CPUs.<br/>An acquire or release on either side<br/>changes nothing."] WHY --> FIX
The store-buffering pattern SB+poonceonces (Result: Sometimes) and why release/acquire cannot repair it. What it shows: both cross-CPU links are fr (from-reads) edges — each CPU’s load ran before the other CPU’s store — and there is no rf (reads-from) edge anywhere in the cycle. The insight to take: this is the structural reason acquire/release fails here, and it is a better mental test than memorising “SB needs smp_mb()”. Release and acquire work by splicing across a reads-from edge; if the consumer never reads the producer’s value, no splice exists and the primitives are inert. Draw the cycle, label each cross-CPU link rf, co or fr, and you can predict the required strength before opening any documentation.
Contrast this with the mirror-image pattern, load buffering (LB), where each CPU loads first and stores second. LB+poonceonces.litmus is Sometimes, so LB also needs ordering — but LB’s links are all reads-from, and consequently the very weakest primitives suffice. LB+poacquireonce+pooncerelease.litmus fixes it with a single acquire and a single release (Never), and LB+fencembonceonce+ctrlonceonce.litmus fixes it with an smp_mb() on one CPU and nothing but a control dependency on the other (Never); the test’s own comment notes “the full memory barrier could be replaced with another control dependency and order would still be maintained.” SB and LB look almost identical on the page — two CPUs, two variables, one store and one load each — and they sit at opposite ends of the cost scale. The difference is entirely in the direction of the links.
A related subtlety is transitivity (multicopy atomicity). A chain of release-acquire pairs orders the CPUs on the chain with respect to each other, but “the ordering provided by a release-acquire chain is local to the CPUs participating in that chain” — a fourth CPU not on the chain may disagree on the global order (memory-barriers.txt, v6.12). The kernel doc’s worked example shows three CPUs in a release-acquire chain that all agree, while a cpu3() outside the chain (using smp_mb()) can still observe a “wrong” order. If you need all CPUs to agree on all operations, use general barriers (smp_mb()) throughout, not a release-acquire chain.
Choosing the Primitive: The Link-Type Rules of Thumb
The previous two sections hinted at a general procedure, and it is worth extracting because it turns memory ordering from folklore into something you can derive. Both the kernel documentation and McKenney’s Is Parallel Programming Hard, And, If So, What Can You Do About It? state the same rule set, from opposite ends: recipes.txt calls it “Rules of thumb”, perfbook numbers them one to four (perfbook v2026.06.21a §15.2.3).
The procedure works on the cycle formed by the accesses in the bad outcome you want to forbid. Walk from CPU to CPU around the cycle and classify each cross-CPU link as one of exactly three kinds (recipes.txt §Rules of thumb, v6.12):
| Link | Formal name | Meaning | Which litmus test is made of these |
|---|---|---|---|
| Write → read | reads-from (rf) | the next CPU reads the value the previous one wrote | LB is entirely rf |
| Read → write | from-reads (fr) | the next CPU overwrites the value the previous one read | SB is entirely fr |
| Write → write | coherence (co) | the next CPU overwrites the value the previous one wrote (C++ calls this “modification order”) | Z6.0 has one co link |
Then apply the rules:
flowchart TB START["Draw the cycle of accesses in the outcome<br/>you want to forbid. Label every cross-CPU link<br/>rf (write→read), fr (read→write), or co (write→write)."] Q0{"Do at least two threads<br/>share at least two variables?"} R0["Rule 1 — no memory-ordering<br/>operation is required at all."] Q1{"How many links are<br/>NOT reads-from?"} R1["ZERO non-rf links<br/>Rule 2 — minimal ordering suffices.<br/>A dependency, a control dependency,<br/>or an acquire. Example: LB."] R2["EXACTLY ONE non-rf link<br/>Rule 3 — a release/acquire pair on<br/>each rf link suffices.<br/>Examples: MP, ISA2, WRC."] R3["TWO OR MORE non-rf links<br/>Rule 4 — you need a full barrier<br/>between EACH pair of non-rf links.<br/>Examples: SB (two fr), Z6.0 (co + fr)."] WARN["If you are stretching these rules<br/>to fit, stop and write the litmus test.<br/>herd7 will answer in seconds."] START --> Q0 Q0 -- no --> R0 Q0 -- yes --> Q1 Q1 -- "0" --> R1 Q1 -- "1" --> R2 Q1 -- "2 or more" --> R3 R1 --> WARN R2 --> WARN R3 --> WARN
The four rules of thumb as a decision procedure. What it shows: the required ordering strength is a function of the shape of the cycle, not of how the code feels. Counting non-reads-from links gives the answer directly. The insight to take: reads-from links are special because, as glossary.txt (v6.12) puts it, they “have the nice property that time must advance from the store to the load, which means that algorithms using reads-from links can use lighter weight ordering.” A store cannot be read before it exists; time itself does half the work. From-reads and coherence links carry no such temporal guarantee — they are statements about what did not happen — and each one you introduce costs a full barrier.
Z6.0+pooncerelease+poacquirerelease+fencembonceonce.litmus is the test that makes Rule 4’s necessity concrete, and it is the crispest possible demonstration that release-acquire chains are not globally ordering. It is Result: Sometimes — the counter-intuitive outcome is allowed, despite a perfectly formed release-acquire chain running through P0 and P1 and a full smp_mb() on P2:
C Z6.0+pooncerelease+poacquirerelease+fencembonceonce
{}
P0(int *x, int *y) { WRITE_ONCE(*x, 1); smp_store_release(y, 1); }
P1(int *y, int *z) { r0 = smp_load_acquire(y); smp_store_release(z, 1); }
P2(int *x, int *z) { WRITE_ONCE(*z, 2); smp_mb(); r1 = READ_ONCE(*x); }
exists (1:r0=1 /\ z=2 /\ 2:r1=0) (* ALLOWED — this really happens *)
flowchart LR subgraph P0["P0 — on the chain"] direction TB A1["WRITE_ONCE(x, 1)"] A2["smp_store_release(y, 1)"] A1 --> A2 end subgraph P1["P1 — on the chain"] direction TB B1["r0 = smp_load_acquire(y) → 1"] B2["smp_store_release(z, 1)"] B1 --> B2 end subgraph P2["P2 — NOT on the chain"] direction TB C1["WRITE_ONCE(z, 2)"] C2["smp_mb()"] C3["r1 = READ_ONCE(x) → 0 ⚠"] C1 --> C2 --> C3 end A2 == "LINK 1: rf<br/>(the chain's only splice)" ==> B1 B2 -- "LINK 2: co — P2's store to z<br/>overwrites P1's" --> C1 C3 -- "LINK 3: fr — P2's load of x ran<br/>before P0's store to x" --> A1 COUNT["Non-rf links: 2 (one co, one fr)<br/>→ Rule 4 applies: a full barrier is needed<br/>between EACH pair of non-rf links.<br/>P2 has one smp_mb(); the chain contributes none.<br/>Verdict: Sometimes."] C1 -.-> COUNT C3 -.-> COUNT
Z6.0, the counterexample that bounds release-acquire chains. What it shows: a textbook release-acquire chain across P0 and P1, a full barrier on P2, and the bad outcome still happens. Counting links explains why: two of the three are non-reads-from, so Rule 4 demands a full barrier between each pair of them, and one smp_mb() on P2 is not enough. The insight to take: the chain’s guarantee is real but local. As perfbook puts it, properly constructed release-acquire chains “form a peaceful (if rather tightly constrained) island of intuitive bliss surrounded by a strongly counter-intuitive sea of more complex memory-ordering constraints.” P2 never joined the island. The fix is not a better chain — it is smp_mb() on P1 as well.
The positive case sits right beside it in the same directory. ISA2+pooncerelease+poacquirerelease+poacquireonce.litmus is the same three-CPU shape with P2 reading z (an rf link) instead of overwriting it, and it is Result: Never. Its comment states the rule in the model’s own words: “in all but one case (P2() to P0()), each process reads from the preceding process’s write. In memory-model-speak, there is only one non-reads-from (AKA non-rf) link, so release-acquire is all that is needed.” Two litmus tests, differing in one access, landing on opposite sides of Rule 3 and Rule 4.
Two practical caveats attach to chains. First, a chain is only as good as its exact values. perfbook is explicit: “the acquire access must load exactly what was stored by the release access. Any intervening store that is not itself part of that same release-acquire chain will break the chain” — a stray writer to the flag variable, even one that means no harm, destroys the ordering for everyone. Second, chains are also stronger than they need to be in the common case: recipes.txt notes of the ISA2 example that “ordering would still be preserved if CPU1’s smp_load_acquire() invocation was replaced with READ_ONCE()”. The middle link of a chain often needs only the release half.
The Floor Beneath Everything: READ_ONCE() and WRITE_ONCE()
Every litmus test above uses READ_ONCE() and WRITE_ONCE() for the accesses that are not acquires or releases, and this is not decoration. The acquire/release contract is stated in terms of memory accesses, and a plain C access is not reliably a memory access at all. Before the CPU gets a chance to reorder anything, the compiler has already rewritten your program — and, as perfbook puts it, “compilers reorder much more aggressively than hardware ever dreamed of doing.” This section covers only what is needed to make the ordering contract meaningful; Compiler Barriers and READ_ONCE WRITE_ONCE is the full treatment.
The definitions are almost disappointingly small (include/asm-generic/rwonce.h, v6.12):
#define __READ_ONCE(x) (*(const volatile __unqual_scalar_typeof(x) *)&(x))
#define READ_ONCE(x) \
({ \
compiletime_assert_rwonce_type(x); \
__READ_ONCE(x); \
})
#define __WRITE_ONCE(x, val) \
do { \
*(volatile typeof(x) *)&(x) = (val); \
} while (0)Line by line: the cast to volatile is the entire mechanism — it forces the compiler to emit exactly one load or store instruction, at that point in the program, reading or writing the actual memory location. __unqual_scalar_typeof() strips const/volatile qualifiers from the result type so the value can be assigned to an ordinary local. compiletime_assert_rwonce_type() is a guard rail: it fails the build unless the access is a native machine word or a long long, because larger types cannot be loaded or stored atomically and the macro would be quietly lying. (The header’s comment is candid about the 64-bit-on-32-bit case: it works “for others we rely on the access being split into 2×32-bit accesses for a 32-bit quantity (e.g. a virtual address) and a strong prevailing wind.”) The header also states the two intended use cases in its own words: “(1) Mediating communication between process-level code and irq/NMI handlers, all running on the same CPU, and (2) Ensuring that the compiler does not fold, spindle, or otherwise mutilate accesses.”
flowchart TB PLAIN["A plain C access to a shared variable:<br/>x = 1; or r = x;"] T1["TEARING — the compiler may split one store<br/>into several smaller ones. A concurrent reader<br/>sees a mashup of the old and new value.<br/>(ordering.txt: 'some compilers will even split<br/>a single store into multiple smaller stores')"] T2["INVENTION — the compiler may add loads or<br/>stores that your source never wrote, e.g.<br/>spilling and reloading a register, or writing a<br/>temporary value before the real one."] T3["FUSING — the compiler may replace a series of<br/>loads with a single load (hoisting it out of a<br/>loop), turning a spin-wait into an infinite loop,<br/>or a series of stores with one store."] T4["ELISION — profile-driven optimisation may turn<br/>x = 1; into if (x != 1) x = 1;<br/>silently converting a store into a LOAD plus a<br/>conditional store. This breaks smp_wmb()."] T5["VALUE SPECULATION — the compiler may guess a<br/>value, use the guess, and check afterwards,<br/>destroying any dependency that flowed<br/>through the loaded value."] PLAIN --> T1 & T2 & T3 & T4 & T5 FIX["READ_ONCE / WRITE_ONCE:<br/>one volatile access, exactly where you put it.<br/>Constrains the COMPILER only —<br/>zero hardware ordering, except same-variable."] T1 --> FIX T2 --> FIX T3 --> FIX T4 --> FIX T5 --> FIX
What an optimising compiler is permitted to do to an unmarked shared-variable access, and what the marked forms take away. What it shows: five distinct transformations, all legal C, all fatal to concurrent code. The insight to take: READ_ONCE()/WRITE_ONCE() buy you only this — they are the floor, not ordering. ordering.txt (v6.12) is precise about the limit: these primitives “required the compiler to emit the corresponding load [or store] instructions in the expected execution order… However, they provide no hardware ordering guarantees, and in fact many CPUs will happily reorder marked reads with each other.” Marked-but-relaxed is one rung above plain C and several rungs below acquire.
Failure T4 is the one that turns an abstract worry into a concrete kernel bug, and ordering.txt gives it in full. Suppose profile-driven optimisation determines that x is almost always already 1. The compiler may then legally rewrite x = 1; smp_wmb(); y = 1; as:
if (x != 1)
x = 1;
smp_wmb(); /* BUG: does not order the reads!!! */
if (y != 1)
y = 1;Your write barrier is still there, and it still orders stores against stores — but the code now contains loads on both sides of it, and smp_wmb() orders nothing about loads. The document’s conclusion is a warning about the future as much as the present: “if you need to use smp_wmb() with unmarked C-language writes, you will need to make sure that none of the compilers used to build the Linux kernel carry out this sort of transformation, both now and in the future.” The same hazard appears in control-dependencies.txt from the other direction: without the WRITE_ONCE(), “the compiler might convert the store into a load and a check followed by a store, and this compiler-generated load would not be ordered by the control dependency.”
There is one honest exemption. ordering.txt lists the circumstances under which unmarked C accesses to shared variables are safe, and the list is short and worth memorising: guard every access with the same lock; or with another synchronisation primitive such as a reader-writer lock or seqlock; or ensure all concurrent accesses are reads; or restrict the variable to statistics and heuristics “where the occasional bogus value can be tolerated”; or declare it _Atomic or volatile. Outside those cases, using plain accesses “requires careful attention to not just your code, but to all the compilers that might be used to build it.” In LKMM terms, a pair of concurrent conflicting accesses of which at least one is unmarked and at least one is a write is a data race, and the model flags it.
Contrast: Dependency Ordering
A third, weaker-still way to order accesses is a dependency — and this is the basis of RCU’s read side (ordering.txt §RCU Read-Side / Control Dependencies; glossary.txt). There are three kinds:
- Address dependency: the value loaded determines the address of a later access.
p = rcu_dereference(gp); x = p->field;— the load ofp->fieldcannot execute untilpis known.rcu_dereference()is exactly an address-dependency-ordered load; it is cheaper than an acquire because it orders the load only against later accesses that depend on the loaded value, not against all subsequent accesses. - Data dependency: the value loaded determines the value a later store writes.
r1 = READ_ONCE(x); WRITE_ONCE(y, r1 + 1);. - Control dependency: the value loaded determines whether a later store executes, via an
if.q = READ_ONCE(a); if (q) WRITE_ONCE(b, 1);.
The catch is that dependencies are fragile. Load-to-store dependencies (data, address, control) are usually preserved because a CPU cannot store before it knows the value, address, or whether to store. But a control dependency to a load is not preserved at all — CPUs may speculate the second load, so a load-load control dependency needs an explicit smp_rmb() (control-dependencies.txt, v6.12). And the compiler can destroy any dependency: if it proves a condition always true it deletes the if; if the dependency is purely syntactic (WRITE_ONCE(y, r1 * 0)) it can drop the loaded value. This is why the LKMM warns that dependency ordering must “take into account all of the compilers used to build the Linux kernel,” and why most new code prefers smp_load_acquire() (robust) over hand-rolled dependency ordering (fragile) — except in RCU, where rcu_dereference()’s address-dependency cheapness is the whole point on the hottest read paths.
Why control dependencies are fragile, concretely
“Fragile” is too vague to act on, so here is the actual failure catalogue. The kernel devotes an entire document to it — tools/memory-model/Documentation/control-dependencies.txt (v6.12) — and it opens with a sentence that is easy to skim past and should not be: “A major difficulty with control dependencies is that current compilers do not support them.” There is no -fpreserve-control-dependencies. The compiler is free to delete the branch you are relying on, and it will not warn you.
flowchart TB SRC["What you wrote:<br/>q = READ_ONCE(a);<br/>if (q)<br/> WRITE_ONCE(b, 1);"] OK["What you MEANT:<br/>the load of a is ordered<br/>before the store to b,<br/>because the branch depends on it"] SRC --> OK F1["FAILURE 1 — the condition is provably true<br/>Compiler emits: q = a; b = 1;<br/>The 'if' is gone; nothing orders anything."] F2["FAILURE 2 — both legs store the SAME value<br/>if (q) { WRITE_ONCE(b,1); f(); }<br/>else { WRITE_ONCE(b,1); g(); }<br/>Compiler hoists the store ABOVE the branch."] F3["FAILURE 3 — arithmetic gives the value away<br/>if (q % MAX) ... with MAX == 1<br/>(q % 1) is always 0; branch deleted."] F4["FAILURE 4 — short-circuit evaluation<br/>if (q || 1 > 0) WRITE_ONCE(b,1);<br/>Second operand always true; branch deleted."] F5["FAILURE 5 — the dependency does not extend<br/>past the end of the 'if'.<br/>Both legs may compile to cmov + one store,<br/>so WRITE_ONCE(c,1) after the if is UNORDERED."] F6["FAILURE 6 — control dependency to a LOAD<br/>if (q) p = READ_ONCE(b);<br/>CPUs SPECULATE loads. No ordering at all.<br/>Needs an explicit smp_rmb()."] OK -.-> F1 OK -.-> F2 OK -.-> F3 OK -.-> F4 OK -.-> F5 OK -.-> F6 CURE["The cure in every case:<br/>smp_store_release() instead of the bare WRITE_ONCE,<br/>or smp_mb(), or smp_load_acquire() on the reader.<br/>barrier() does NOT help — it respects the letter<br/>of the law while the branch is deleted anyway."] F1 --> CURE F2 --> CURE F3 --> CURE F4 --> CURE F5 --> CURE F6 --> CURE
The six documented ways a control dependency evaporates, all from control-dependencies.txt (v6.12). What it shows: five of the six are the compiler removing the conditional branch that the ordering rests on, and one is the CPU speculating past a branch it did preserve. The insight to take: control-dependency ordering is a property of the emitted machine code, not of your C. The document is emphatic that “the conditional is absolutely required, and must be present in the final assembly code, after all of the compiler and link-time optimizations have been applied”, and that adding a barrier() cannot rescue it — “the conditional is gone, and the barrier won’t bring it back.” This is why smp_load_acquire() is the default and control dependencies are a specialist tool for the very hottest paths.
Failure 2 deserves a closer look because it is the one that catches careful people. The obvious defensive move — put a barrier() in each leg of the if — is explicitly documented as insufficient. control-dependencies.txt shows the transformation compilers actually perform at high optimisation levels: both identical WRITE_ONCE(b, 1) calls are hoisted above the branch, leaving q = READ_ONCE(a); barrier(); WRITE_ONCE(b, 1); if (q) {...} else {...}. Every barrier() was honoured; the ordering is gone anyway. The document’s rule is blunt: if both legs store the same value to the same variable, you must use smp_store_release() or bracket both with smp_mb(). Ordering by control dependency is guaranteed only when the stores differ.
Failure 5 is subtle in a different way. It is tempting to argue that since the compiler may not reorder volatile accesses, and may not move the writes to b across the condition, the following WRITE_ONCE(c, 1) must also be ordered. The document’s counter-argument is a machine-code one: the compiler may render both legs as conditional-move instructions, so the final assembly is ld r1,a / cmp / cmov,ne / cmov,eq / st r4,b / st $1,c — the only thing the branch controls is the pair of cmovs, and the store to c hangs off nothing at all. The summary rule: “control dependencies apply only to the then-clause and else-clause of the if statement in question (including functions invoked by those two clauses), and not to code following that if statement.”
The kernel does have a first-class way to upgrade a control dependency into a real acquire, and reading its definition is the clearest possible statement of what a control dependency is worth. include/asm-generic/barrier.h (v6.12) defines:
/**
* smp_acquire__after_ctrl_dep() - Provide ACQUIRE ordering after a control dependency
*
* A control dependency provides a LOAD->STORE order, the additional RMB
* provides LOAD->LOAD order, together they provide LOAD->{LOAD,STORE} order,
* aka. (load)-ACQUIRE.
*/
#define smp_acquire__after_ctrl_dep() smp_rmb()flowchart LR CD["Control dependency<br/>gives LOAD → STORE"] RMB["smp_rmb()<br/>gives LOAD → LOAD"] ACQ["= LOAD → {LOAD, STORE}<br/>which is exactly an acquire"] CD --> ACQ RMB --> ACQ USE["Used by smp_cond_load_acquire():<br/>spin on smp_cond_load_relaxed() until the<br/>condition holds — that spin IS a control<br/>dependency — then add the rmb."] ACQ --> USE
Acquire, decomposed into its two halves. What it shows: an acquire load is precisely the conjunction of two weaker orderings — a load-to-store order and a load-to-load order — and the kernel builds one from the other two where a spin loop already supplies the first for free. The insight to take: this is not a curiosity; it is how smp_cond_load_acquire() is implemented generically, and therefore how MCS locks and qspinlock acquire ordering on architectures that lack a cheap acquire instruction. It also makes the earlier claim precise: a control dependency is exactly “half an acquire”, missing the load-to-load half — which is why control-dependencies.txt says a load-load control dependency needs an smp_rmb() bolted on.
rcu_dereference() versus smp_load_acquire()
Both are ordered loads, both pair with a release store, and they are not interchangeable. The difference is visible in the source. In v6.12, rcu_assign_pointer() is a release store — include/linux/rcupdate.h defines it as smp_store_release(&p, RCU_INITIALIZER(...)), with one optimisation: if the assigned value is a compile-time-constant NULL, it degrades to a bare WRITE_ONCE(), because publishing a null pointer publishes no prior initialisation and needs no ordering. But rcu_dereference() is not an acquire load. Its core, __rcu_dereference_check(), is a plain READ_ONCE(p) with a comment reading /* Dependency order vs. p above. */ and nothing else. There is no barrier instruction on the reader at all.
smp_load_acquire(&p) | rcu_dereference(p) | |
|---|---|---|
| Implementation (v6.12) | LDAR on arm64; lwsync-preceded load on PPC; plain load + barrier() on x86 | READ_ONCE(p) on every architecture — no fence, ever |
| Orders the load against | all subsequent loads and stores | only accesses whose address/value/execution depends on the loaded value |
| Survives compiler cleverness | yes — it is an explicit barrier | no — the compiler can break the dependency chain |
| Pairs with | smp_store_release() | rcu_assign_pointer() (which is a release store) |
| Cost on a hot read path | one instruction on arm64/PPC/RISC-V | zero instructions |
| Use it when | ordering must be robust and general | you are inside rcu_read_lock() and the read path is measurably hot |
The two publish-subscribe subscribers compared. What it shows: they differ not in the publisher — both pair with a release store — but entirely in what the reader pays and what the reader gets. The insight to take: rcu_dereference() is the cheapest ordered load in the kernel because it buys ordering with a data hazard the CPU already had to respect, rather than with a fence. The price is that the guarantee is contingent on the compiler leaving the dependency intact, which is why RCU ships an entire rulebook — Documentation/RCU/rcu_dereference.rst — for keeping it alive.
That rulebook is worth reading in full if you use RCU, but one entry shows the flavour better than a summary. Comparing an RCU-protected pointer against a known non-NULL address can silently destroy the dependency, because the comparison teaches the compiler the pointer’s exact value:
p = rcu_dereference(gp);
if (p == &default_struct)
do_default(p->a); /* looks fine... */The compiler now knows that inside the if, p is &default_struct, so it may rewrite do_default(p->a) as do_default(default_struct.a) — a load from a fixed address that no longer depends on the rcu_dereference() at all. “On ARM and Power hardware, the load from default_struct.a can now be speculated, such that it might happen before the rcu_dereference()” (rcu_dereference.rst, v6.12). The same document forbids carrying dependencies through relational operators (>, <=, …) for the same reason — they compile to branches, and branches to loads are speculated — and warns against arithmetic that cancels, such as p - (uintptr_t)p, “the compiler is within its rights to substitute zero for this sort of expression”. Note that all of this is a compiler hazard, not an Alpha hazard: since v4.15 READ_ONCE() itself carries the implicit address-dependency barrier that DEC Alpha used to require, and v5.9 removed the explicit smp_read_barrier_depends() API entirely (memory-barriers.txt §Address-dependency barriers (historical), v6.12).
A-Cumulativity: Why a Release Beats smp_wmb() Across Three CPUs
Everything so far has been about two CPUs. Extend to three and a property appears that has no two-CPU analogue, and that is the strongest technical argument for preferring smp_store_release() over WRITE_ONCE()-plus-smp_wmb().
The property is A-cumulativity. explanation.txt (v6.12) defines it operationally in terms of store propagation: when a fence executes on CPU C, “any store which propagates to C before a release fence is executed (including all po-earlier stores executed on C) is forced to propagate to [every other CPU] before the store associated with the release fence does.” The parenthesis is the ordinary two-CPU behaviour; the words before the parenthesis are the extra. A release orders not only the stores this CPU made, but also stores other CPUs made that this CPU has already seen. And then the contrast, stated flatly in the same document: “By contrast, smp_wmb() fences are not A-cumulative; they only affect the propagation of stores that are executed on C before the fence.”
flowchart LR subgraph P0["P0 — the original writer"] A["WRITE_ONCE(x, 1)"] end subgraph P1["P1 — the relay"] direction TB B1["r0 = READ_ONCE(x) → 1"] B2["smp_store_release(y, 1)"] B1 --> B2 end subgraph P2["P2 — the far reader"] direction TB C1["r0 = READ_ONCE(y) → 1"] C2["smp_rmb()"] C3["r1 = READ_ONCE(x) → must be 1"] C1 --> C2 --> C3 end A == "rf" ==> B1 B2 == "rf" ==> C1 B1 -. "A-CUMULATIVITY:<br/>P0's store to x was already visible to P1,<br/>so the release drags it along —<br/>it must reach P2 before the store to y does" .-> C3 V["WRC+pooncerelease+fencermbonceonce+Once: Never<br/>Replace the release with smp_wmb() and<br/>the guarantee is GONE — smp_wmb() only<br/>pushes P1's OWN earlier stores."] C3 --> V
The WRC (write-read-causality) litmus test, and what A-cumulativity buys. What it shows: P1 never wrote x; it merely read it. A release store on P1 nonetheless forces P0’s store to x to reach P2 before P1’s own store to y does. The insight to take: a release store is a promise about everything the releasing CPU has observed, not just everything it has written. smp_wmb() makes the narrower promise, covering only stores that CPU executed itself. The kernel’s litmus-tests/README names this as the reason the test is forbidden: “the second is forbidden because smp_store_release() is A-cumulative in LKMM.” In the formal model both appear in cumul-fence, but only the release and strong-fence arms are wrapped in A-cumul(...) — let cumul-fence = [Marked] ; (A-cumul(strong-fence | po-rel) | wmb | ...) (linux-kernel.cat line 90, v6.12). The wmb term sits outside the wrapper. That one line of cat is the whole difference.
This is the deep reason the documentation keeps saying “you are usually better off using a release store” rather than smp_wmb(). It is not only readability. In any relay or hand-off pattern — a work item passing through more than one CPU, a chain of list insertions, a value read on one CPU and republished on another — the release is genuinely stronger, and the strength is invisible until the third CPU shows up.
Hardware Mapping
The reason acquire/release is preferred is that on the dominant architectures it is cheaper than a full fence — and on x86 it is essentially free. Verified directly against the v6.12 kernel headers:
ARM64 (weakly-ordered) has dedicated load-acquire and store-release instructions (arch/arm64/include/asm/barrier.h, v6.12):
#define __smp_store_release(p, v) ... asm volatile ("stlr %x1, %0" ...) // STLR
#define __smp_load_acquire(p) ... asm volatile ("ldar %0, %1" ...) // LDAR
#define __smp_mb() dmb(ish) // full barrier: DMB ISH
#define __smp_rmb() dmb(ishld) // read barrier: DMB ISHLD
#define __smp_wmb() dmb(ishst) // write barrier: DMB ISHSTSo smp_store_release() is a single STLR (store-release register) and smp_load_acquire() a single LDAR (load-acquire register) — the hardware natively understands the one-way semantics, no separate DMB fence instruction needed. A full smp_mb() is a DMB ISH (data memory barrier, inner-shareable), which is more expensive.
x86 is TSO, where loads already have acquire semantics and stores already have release semantics in hardware. So acquire/release need no fence instruction at all — only a compiler barrier (arch/x86/include/asm/barrier.h, v6.12):
#define __smp_store_release(p, v) \
do { \
compiletime_assert_atomic_type(*p); \
barrier(); \
WRITE_ONCE(*p, v); \
} while (0)
#define __smp_load_acquire(p) \
({ \
typeof(*p) ___p1 = READ_ONCE(*p); \
compiletime_assert_atomic_type(*p); \
barrier(); \
___p1; \
})
#define __smp_wmb() barrier() /* compiler-only */
#define __smp_rmb() dma_rmb() /* which is also barrier() */
#define __smp_mb() asm volatile("lock; addl $0,-4(rsp)" ::: "memory", "cc")`.
**PowerPC and RISC-V** fill in the middle of the picture, and both confirm the same shape: acquire/release get a *lighter* fence than `smp_mb()` does. On PowerPC, release and acquire are built from `lwsync` (lightweight sync) while `smp_mb()` must use the full `sync`; the header explains why in a comment: "we have to use the `sync` instruction for `smp_mb()`, since `lwsync` doesn't order loads with respect to previous stores" — which is exactly the SB pattern from earlier in this note, appearing here as a hardware fact ([`arch/powerpc/include/asm/barrier.h`, v6.12](https://raw.githubusercontent.com/torvalds/linux/v6.12/arch/powerpc/include/asm/barrier.h)). On RISC-V the fences are typed by which operations they order on each side, so acquire and release are spelled directly as half-fences ([`arch/riscv/include/asm/barrier.h`, v6.12](https://raw.githubusercontent.com/torvalds/linux/v6.12/arch/riscv/include/asm/barrier.h)).
| Primitive | x86-64 | arm64 | PowerPC | RISC-V |
|---|---|---|---|---|
| `smp_store_release()` | `barrier()` + `MOV` (**no instruction**) | **`STLR`** — one instruction, no fence | `lwsync` + store | `fence rw,w` + store |
| `smp_load_acquire()` | `MOV` + `barrier()` (**no instruction**) | **`LDAR`** — one instruction, no fence | load + `lwsync` | load + `fence r,rw` |
| `smp_wmb()` | `barrier()` (**none**) | `DMB ISHST` | `lwsync` (or `eieio`/`mbar` on older sub-arches) | `fence w,w` |
| `smp_rmb()` | `barrier()` (**none**) | `DMB ISHLD` | `lwsync` | `fence r,r` |
| `smp_mb()` | `lock; addl $0,-4(%rsp)` — **expensive** | `DMB ISH` | **`sync`** — the heavyweight one | `fence rw,rw` |
| `smp_mb__after_atomic()` | `do { } while (0)` — **free** | (generic) | (generic) | (generic) |
*How the contract lowers to instructions on four architectures, read from the v6.12 arch headers. **What it shows:** on x86 everything except `smp_mb()` is free; on arm64 acquire and release are single dedicated instructions rather than fences; on PowerPC and RISC-V they are strictly lighter fences than the full barrier. **The insight to take:** the gap between the acquire/release row and the `smp_mb()` row is real money on every architecture, and it is *widest* on x86 — where the difference is between zero instructions and a locked RMW. This is the quantitative argument for choosing the weakest sufficient primitive, and it is also a warning: because the difference is invisible on x86 in *correctness* terms too (TSO hides many missing barriers), x86 is the worst possible platform on which to test whether your ordering is right. See [[Cache Coherence and the Store Buffer]] for why the hardware is shaped this way.*
Two footnotes on the table. First, when an architecture supplies no override, the generic fallback in `include/asm-generic/barrier.h` defines `__smp_store_release()` as `__smp_mb(); WRITE_ONCE(...)` and `__smp_load_acquire()` as `READ_ONCE(...); __smp_mb()` — correct but pessimal, a full barrier where a half would do. Second, `smp_mb__before_atomic()` and `smp_mb__after_atomic()` exist precisely so that the `atomic_inc()`-then-order pattern is free on TSO: x86 defines both as `do { } while (0)` because its atomics are already fully ordered, while weakly-ordered architectures emit a real fence. `ordering.txt` shows the payoff directly — writing `smp_mb()` after an `atomic_inc()` is "inefficient on x86!!!" (the document's own exclamation marks), whereas `smp_mb__after_atomic()` "emits code only on CPUs whose `atomic_inc()` implementations do not guarantee full ordering." The document adds one usage rule that is easy to violate: **do not put code between the `smp_mb__*()` and the atomic it augments**, "because the ordering of this intervening code will differ from one CPU architecture to another."
## Common Misunderstandings and Failure Modes
- **Using release/acquire for store-buffer ordering.** The most common bug: a store followed by a load that must not reorder needs `smp_mb()`, not an acquire or release. Release/acquire is a one-way gate; SB needs a two-way gate. Diagnose by drawing the pattern: if the ordering you need is store→load on the *same* CPU, no acquire/release will help.
- **Forgetting the reads-from match.** A release publishes nothing unless some acquire *reads the released value*. An acquire that reads a stale value (still 0) is not ordered against the producer at all — `smp_load_acquire()` "is not magic... it does not ensure that any particular value will be read" ([`memory-barriers.txt`, v6.12](https://raw.githubusercontent.com/torvalds/linux/v6.12/Documentation/memory-barriers.txt)).
- **Expecting global agreement from a release-acquire chain.** Only CPUs *on the chain* agree on the order; off-chain CPUs may not. Use `smp_mb()` if you need total agreement.
- **Mixing the wrong member of a compound RMW.** For `atomic_xchg_acquire()`, only the *load* half is ordered; for `cmpxchg_release()`, only the *store* half — and conditional forms order *only on success*.
- **Relying on a fragile dependency where an acquire belongs.** Control-dependency-to-load gives no ordering; a compiler can erase data/address dependencies. Outside RCU's `rcu_dereference()` fast path, reach for `smp_load_acquire()`.
- **Pairing across mismatched variables.** A release to `flag` only synchronizes with an acquire that loads *`flag`*. An acquire of a different variable establishes nothing.
- **Assuming atomic means ordered.** `atomic_inc()` is perfectly atomic and orders *nothing*. So does `set_bit()`. Reference-count code is the classic victim: `obj->dead = 1; atomic_dec(&obj->ref_count);` needs an `smp_mb__before_atomic()` between the two lines, or the death mark can become visible after the count drops ([`memory-barriers.txt` §CPU memory barriers, v6.12](https://raw.githubusercontent.com/torvalds/linux/v6.12/Documentation/memory-barriers.txt)).
- **Trusting a failed conditional operation.** A failed `cmpxchg()`, a failed `spin_trylock()`, a `test_and_set_bit_lock()` that lost the race — none of them order anything, whatever suffix they carry. `atomic_t.txt`: "conditional operations are still unordered on FAILURE."
- **A stray store into the chain variable.** perfbook: "the acquire access must load exactly what was stored by the release access. Any intervening store that is not itself part of that same release-acquire chain will break the chain." A debug counter or a reset path writing the flag variable is enough.
- **Testing only on x86.** TSO hides missing `smp_wmb()`, missing `smp_rmb()`, and every dependency mistake; the only thing it exposes is a missing `smp_mb()` in an SB pattern. Code that has run for years on x86 can corrupt a list on the first ARM64 server. If you cannot test on weak hardware, write the litmus test and run `herd7`.
- **Assuming a barrier makes stores propagate faster.** It does the opposite: it delays *your own* subsequent operations until propagation has happened. `memory-barriers.txt` lists this among its explicit non-guarantees — "the barrier can be considered to draw a line in that CPU's access queue."
- **Reaching for barriers when a lock would do.** By far the most common failure in review. If the data has an owner lock, the lock's acquire and release are already correct, already paired, and already checked by `lockdep`.
## Production Notes
The best way to see this contract in production is to read the primitives it is built into, all in the v6.12 tree.
**MCS locks — the canonical release/acquire pair.** `kernel/locking/mcs_spinlock.h` is a two-line demonstration of the whole note. The waiter spins with `smp_cond_load_acquire(l, VAL)`, whose comment reads: "using `smp_cond_load_acquire()` provides the acquire semantics required so that subsequent operations happen after the lock is acquired. Additionally, some architectures such as ARM64 would like to do spin-waiting instead of purely spinning, and `smp_cond_load_acquire()` provides that behavior." The releaser calls `smp_store_release((l), 1)` — "provides a memory barrier to ensure all operations in the critical section has been completed before unlocking." That is MP: publish the critical section's writes, then flag. The header then adds exactly the caveat this note has been building toward: "the `smp_load_acquire`/`smp_store_release` pair is not sufficient to form a full memory barrier across cpus for many architectures (except x86) for `mcs_unlock` and `mcs_lock`. For applications that need a full barrier across multiple cpus… `smp_mb__after_unlock_lock()` should be used after `mcs_lock`." A release-acquire pair is not a full barrier, stated in a comment above the code that depends on it.
**`qspinlock` — a release, an acquire, and an `smp_wmb()`, each for a different reason.** `kernel/locking/qspinlock.c` uses `smp_cond_load_acquire(&lock->locked, !VAL)` in the pending-bit path, with a comment that names the pairing explicitly: "this wait loop must be a load-acquire such that we match the store-release that clears the locked bit and create lock sequentiality; this is because not all `clear_pending_set_locked()` implementations imply full barriers." Further down, before publishing the queue node's address via `xchg_tail()`, it uses `smp_wmb()`: "ensure that the initialisation of `@node` is complete before we publish the updated tail." A store-to-store publication where the publishing store is an RMW — exactly the case where `smp_wmb()` is the right tool and a release store is awkward. See [[Queued Spinlocks and qspinlock]].
**`rcu_assign_pointer()` / `rcu_dereference()` in a real subsystem.** `lib/math/prime_numbers.c` (v6.12) is the example `recipes.txt` cites, and it still holds: `expand_to_next_prime()` publishes a newly grown bit vector with `rcu_assign_pointer(primes, new)` and `next_prime_number()` subscribes with `rcu_dereference(primes)`. The publisher is a release store; the subscriber is a bare `READ_ONCE()` relying on the address dependency. See [[The publish-subscribe Pattern in RCU]].
> [!warning] Uncertain
>
> Verify: `recipes.txt`'s claim that "the `init_stack_slab()` function in `lib/stackdepot.c` uses release-acquire in this way". Reason: **this pointer is stale in v6.12.** Reading `lib/stackdepot.c` at the v6.12 tag shows no function named `init_stack_slab()` (the nearest equivalent is `depot_init_pool()`) and **zero occurrences of `smp_store_release()` or `smp_load_acquire()` in the entire file**; the publication is now done with `WRITE_ONCE(pools_num, pools_num + 1)` under `pool_lock`, carrying the comment "Pairs with concurrent `READ_ONCE()` in `depot_fetch_stack()`". The `recipes.txt` text was not updated when stackdepot was reworked. To resolve: `git log --follow lib/stackdepot.c` to date the rework, and file a documentation fix. The *technique* `recipes.txt` describes is correct; only the in-tree pointer is wrong. Treat this as the general lesson — **in-tree documentation goes stale; verify every code reference against the tag you are reading.** `#uncertain`
**Running the tests yourself.** Nothing in this note has to be taken on faith. `tools/memory-model/README` (v6.12) describes the workflow: the model is written in the `cat` language and executed by **`herd7`**, which "exhaustively explores the state space of small litmus tests"; a companion tool, **`klitmus7`**, converts a litmus test into a loadable kernel module so the same test runs on real silicon. Both come from [herdtools7](https://github.com/herd/herdtools7) (version 7.52 or higher; kernels 5.17 and later want 7.56.1+). Every `Result: Never` and `Result: Sometimes` quoted above is a comment in a file you can feed to `herd7` in a few seconds — and when a rule of thumb does not obviously apply, `recipes.txt`'s own advice is to stop reasoning and run the tool: "if you find yourself having to stretch these rules of thumb to fit your situation, you should consider creating a litmus test and running it on the model."
**And the standing recommendation.** `ordering.txt` closes its treatment of unmarked accesses with a sentence that generalises to this whole note: "used properly, unmarked C-language accesses can reduce overhead on fastpaths. However, the price is great care and continual attention to your compiler as new versions come out and as new optimizations are enabled." Substitute "lock-free algorithms" for "unmarked accesses" and it is the honest summary. Acquire/release is the right tool when you have decided to pay that price deliberately, on a path where the measurement justifies it. Everywhere else, the kernel already has a lock with the barriers in it.
## See Also
- [[Cache Coherence and the Store Buffer]] — **the other half of this pair**: the hardware mechanism (store buffer, invalidate queue, MESI, x86-TSO) that makes all of this ordering necessary in the first place. This note is the contract; that one is the machine
- [[The Linux Kernel Memory Model]] — the formal model defining these ordering strengths, the six axioms, and the `acq-po`/`po-rel`/`cumul-fence` machinery; `herd7` and the `cat` language
- [[Memory Barriers in the Linux Kernel]] — `smp_mb`/`smp_wmb`/`smp_rmb`, the mandatory `mb()` family, and the `dma_*()` and MMIO barriers this note does not cover
- [[Compiler Barriers and READ_ONCE WRITE_ONCE]] — the compiler as an independent reordering engine; the full treatment of the marked accesses acquire/release builds on
- [[Kernel Atomic Operations and atomic_t]] and [[Compare-and-Swap and cmpxchg in the Kernel]] — the RMW operations whose `_relaxed`/`_acquire`/`_release` suffixes are classified above
- [[Atomic Bit Operations]] — `set_bit()`/`clear_bit_unlock()`/`test_and_set_bit_lock()` and their ordering, which follows the same rule of thumb as `atomic_t`
- [[Kernel Spinlocks]], [[Queued Spinlocks and qspinlock]] and [[Kernel Mutexes]] — the locking primitives that already carry the right barriers, and where `smp_mb__after_spinlock()` upgrades them to RCsc
- [[Read-Copy-Update Fundamentals]] and [[The publish-subscribe Pattern in RCU]] — `rcu_assign_pointer()`/`rcu_dereference()` as the release/dependency-acquire pairing
- [[Sequence Locks and seqlock]] — a reader/writer scheme built entirely from `smp_wmb()` and `smp_rmb()` rather than acquire/release
- [[Happens-Before Relation]] and [[Sequential Consistency]] — the partial order a release-acquire pair constructs, and the total order it deliberately does not
- [[Total Store Order and Relaxed Memory Models]] — the architecture-neutral view of the per-architecture lowering table above
- [[Atomicity Visibility and Ordering]] — the three-way split this note's "atomic ≠ ordered" warning refines
- [[Go Memory Model]] and [[The C Plus Plus Memory Model and Atomic Ordering]] — language-level acquire/release (`sync/atomic`, `memory_order_acquire`/`_release`); same concept, different layer
- [[Linux Kernel Synchronization MOC]] — parent map (section A, Foundations)
- [[Concurrency and Parallelism MOC]] — parent map (section 2, Memory Models and Ordering)