Systems Programming · Concurrency Primitives

Acquire and Release: What Each Ordering Forbids

The precise prohibitions that acquire and release each impose, why the pair cannot order a store against a later load, and when a standalone fence is the only option.

The names are misleading in a way that costs correctness. An acquire operation does not “acquire” memory and a release operation does not “release” it; both are partial barriers, and each forbids exactly half of the reorderings a full barrier would forbid. Treating them as interchangeable with sequential consistency produces code that is over-constrained in one place and, more often, under-constrained in another. Getting the asymmetry right is a matter of knowing which direction each one cuts.

One-way barriers

An acquire operation prevents later operations from moving before it. A release operation prevents earlier operations from moving after it. Neither does the reverse.

Concretely, a load with acquire semantics guarantees that every memory operation sequenced after it in the same thread appears to occur after it in the global memory order visible to other threads. A store with release semantics guarantees that every memory operation sequenced before it appears to occur before it.

The asymmetry follows from what each is used for. Acquire is placed on the load that reads a flag, and the concern is that subsequent reads of the guarded data might be issued before the flag’s value is known. Release is placed on the store that publishes, and the concern is that earlier writes to the guarded data might not yet be visible when the flag is set. In both cases the barrier sits at the boundary between the flag and the data, and the direction it blocks is the direction toward the flag.

A release store does not prevent a later load from being reordered before it. That is not a defect, because a release store is normally the last thing a publishing thread does; anything after it is not part of what it published. If the code does need a later load to stay after the store, the release store is the wrong tool and the section below on fences applies.

The two-store, two-load pattern

The canonical exchange is a message passing arrangement. One thread writes data and then publishes; another acquires the publication and then reads the data.

#include <stdatomic.h>

static int data[4];
static atomic_int published;

/* writer */
for (int i = 0; i < 4; i++)
    data[i] = i;                                   /* plain stores */
atomic_store_explicit(&published, 1, memory_order_release);

/* reader */
while (atomic_load_explicit(&published, memory_order_acquire) == 0)
    ;
int seen = data[0];                                /* guaranteed to be 0 */

The release store orders the four plain stores before it, and the acquire load orders the read of data[0] after it. Because the acquire load reads the value the release store wrote, the release store synchronizes with the acquire load, and the stores happen-before the read. On x86-64 this compiles to plain moves on both sides, since total store order already forbids the reorderings that matter here. On Armv8-A the store becomes stlr and the load ldar, and the guarantee comes from those encodings.

Note what is doing the work: the synchronizes-with edge, not the ordering flags in isolation. If the reader’s acquire load reads a value written by a relaxed store, no synchronizes-with edge exists and the acquire provides no visibility for the earlier writes. Acquire and release are only useful as a matched pair on the same object.

Release sequences

The standard extends synchronizes-with beyond a direct read of the releasing store through the notion of a release sequence. A release sequence headed by a release store consists of that store followed by a chain of read-modify-write operations on the same object by any thread, optionally followed by further relaxed stores by the same thread that performed the release store.

The practical consequence concerns reference counting and flag clearing. A producer releases a store of 1; several threads then perform fetch-subtract on the same counter as they finish their work; the last one sees the decrement to zero and cleans up. Each of those read-modify-writes continues the release sequence, so a thread that acquires a value from the middle of the chain still synchronizes with the original release. Without that rule, every decrementing thread would need to use release ordering itself, and the chain would break at the first relaxed operation.

This is why the usual reference-count decrement uses memory_order_acq_rel on the decrement that might reach zero, or memory_order_release followed by a separate acquire fence before the destruction path. The fence form exists because the ordering needed by the destructor is on the load side of a branch that has already been taken, and the next section explains why a load ordering cannot be attached there.

When only a fence will do

A standalone fence imposes ordering without being attached to a particular operation, and its rules are asymmetric in a way that trips up most first attempts.

The C and C++ interfaces provide atomic_thread_fence. A fence with memory_order_acquire orders prior loads with respect to subsequent operations; a fence with memory_order_release orders prior operations with respect to subsequent stores; a sequentially consistent fence is stronger than either.

The critical asymmetry: a release fence must be followed by a store, and only then does it order preceding operations against that store. An acquire fence must be preceded by a load, and only then does it order that load against subsequent operations. A fence with no operation of the matching kind on the correct side does nothing.

The reference-counting destructor shows the pattern:

static atomic_int refs;

static void put(obj_t *o)
{
    if (atomic_fetch_sub_explicit(&refs, 1, memory_order_acq_rel) == 1) {
        /* this thread observed the last reference: all prior readers' work
           is visible here because the acq_rel decrement synchronized with
           the release increments, and the release sequence covers the rest */
        destroy(o);
    }
}

The decrement carries both orderings because it is a read-modify-write and can act as both the acquire side and the release side. When the decrement is performed with release ordering only, the acquire side has to be supplied by a fence placed after the load that observed the zero:

if (atomic_fetch_sub_explicit(&refs, 1, memory_order_release) == 1) {
    atomic_thread_fence(memory_order_acquire);   /* must follow the load */
    destroy(o);
}

The fence orders the loads that produced the observed value against the operations inside destroy. Put it before the if and it orders nothing that matters; the standard’s rule about which side of the load it sits on is what makes the difference, and getting it wrong produces a program that fails rarely and unpredictably on weakly ordered hardware.

What sequential consistency adds

An acquire/release pair gives a happens-before edge but not a single global order. Sequential consistency adds a total order over all sequentially consistent operations that is consistent with each thread’s program order, and every thread observes those operations in that same order.

The distinction shows up in the store-buffering litmus test, where two threads each write a flag and then read the other’s flag. Under acquire/release, both threads can read 0, because each store may sit in its own store buffer while the other thread’s load bypasses it. Under sequential consistency that outcome is forbidden. The test and its variants are worked through in Litmus Tests and Store Buffering.

Sequential consistency is the default for the plain atomic_load and atomic_store forms, and it is the right choice for any flag whose consumers reason about relative order rather than about visibility. The cost is architectural: on x86-64 a sequentially consistent store requires a locked instruction or a fence, and on Armv8-A it requires barriers around the store in positions where stlr alone is insufficient. Acquire and release avoid those costs and are sufficient for any publication of data behind a flag.

Fences are not free, and often unnecessary

A fence constrains the compiler and the processor across a range of operations, while an acquire load or release store constrains only the operations that pass through it. On architectures where the ordering is encoded in the load or store instruction, the fence is strictly weaker as an optimisation target: it forces the compiler to treat every memory operation in the region as ordered, which inhibits scheduling and register allocation over a wider span.

Two heuristics follow. Prefer the per-operation ordering when there is a natural load or store at the boundary, which there almost always is. The lock acquisition and release paths in Spinlock Design from Test-and-Set to Ticket Locks are the archetype: one acquire on the exchange, one release on the store, and nothing else. Reach for a fence when the boundary is a control-flow edge rather than an operation, as in the destructor above, or when a batch of stores must be published by a single flag that is written through a plain store because it lives in memory shared with hardware that cannot read an atomic type.

There is one more case where fences matter and atomics do not: communication with a device or with a signal handler on the same core. The standard’s atomics describe inter-thread communication, and a signal handler is not a thread in the sense the model uses. The portable mechanism there is volatile sig_atomic_t plus whatever the platform documents, and a fence is often the only ordering primitive that has defined meaning in both contexts.

The last limitation is that neither ordering says anything about durability or about ordering against I/O. An acquire load does not flush a store buffer to memory, and a release store does not force a write to reach the storage device. Those are properties of the platform’s cache and I/O ordering rules, and on some architectures they require a separate barrier that the C atomics do not provide.

References

  1. ISO/IEC 9899:2018 Programming Languages C — ISO/IEC
  2. ISO/IEC 14882:2020 Programming Languages C++ — ISO/IEC
  3. A Tutorial Introduction to the ARM and POWER Relaxed Memory Models — Luc Maranget, Susmit Sarkar, Peter Sewell
  4. ARM Architecture Reference Manual for A-profile architecture — Arm Limited
  5. Intel 64 and IA-32 Architectures Software Developer Manuals — Intel Corporation