Computer Architecture · Memory Consistency

Acquire and Release as One-Way Ordering Constraints

How a release store and an acquire load each constrain ordering in only one direction, and why the pair is enough to publish data without a full fence.

A full fence orders everything before it against everything after it. That is more ordering than most synchronisation needs, and on architectures where a fence is a pipeline drain, the surplus is paid in cycles. The acquire/release pair exists to buy only the ordering a pattern actually uses, and it is the form in which essentially all modern synchronisation is expressed.

The idea is asymmetric. A release operation constrains operations before it: they must not be reordered after it. An acquire operation constrains operations after it: they must not be reordered before it. Neither constrains the other direction, because nothing in the pattern needs that direction.

Why one direction is enough for publication

The publication pattern is a write of data followed by a write of a flag, and on the reader side a read of the flag followed by a read of the data. Correctness requires that if the reader sees the flag set, it also sees the data. It does not require that a store issued after the flag store be ordered after it, nor that a load issued before the flag load be ordered before it.

So the writer needs store-store ordering across one point and nothing else. The reader needs load-load ordering across one point and nothing else. Requiring full fences at both ends would supply ordering in three directions that the pattern never uses, and on a machine that implements a fence as a drain, each of those directions is a stall.

The formal statement uses the happens-before relation. A release store to flag and an acquire load of flag that reads the stored value create a synchronises-with edge from the store to the load. Everything sequenced before the release store happens-before everything sequenced after the acquire load. Within that edge, ordinary non-atomic accesses are ordered, and no fence is needed at either site to make the data visible.

#include <stdatomic.h>
#include <stddef.h>

struct msg { int value; int seq; };
static struct msg slot;
static _Atomic(struct msg *) published = NULL;

void produce(int v, int s) {
    slot.value = v;                                   // plain stores
    slot.seq = s;
    atomic_store_explicit(&published, &slot, memory_order_release);
}

int consume(int *out) {
    struct msg *m = atomic_load_explicit(&published, memory_order_acquire);
    if (m == NULL)
        return 0;
    out[0] = m->value;                                // cannot move above the acquire
    out[1] = m->seq;
    return 1;
}

The two plain stores in produce are not atomic and carry no ordering of their own. Their visibility is guaranteed entirely by the release store that follows them and the acquire load that reads it. This is the property that makes the pair useful: it lets a whole data structure be published with one ordered store rather than requiring every field to be atomic.

What the four combinations compile to

The interesting engineering content is that acquire and release are not symmetric in cost, and the asymmetry differs by architecture.

On x86, TSO already preserves store-store order, so a release store needs no instruction at all. It is a plain MOV. An acquire load is also free at the hardware level, because TSO does not reorder loads with later loads or stores. Compilers still emit nothing for the acquire, but they must not move code across it, which is a compiler constraint rather than an instruction. This is why lock-prefixed operations dominate x86 synchronisation cost: the ordering is free, and the atomicity is what costs.

On Arm, both directions need explicit instructions. A release store is STLR, which carries release semantics in the instruction encoding. An acquire load is LDAR. Neither is a full DMB barrier; they constrain only the one direction, which is why they are cheaper than a DMB on the same core. The architecture provides them precisely because the common patterns need only one direction and a DMB on every synchronisation site would be wasteful.

On architectures with weaker or differently shaped models, the compiler emits the appropriate fence around a plain access, and the resulting code may be a fence on both sides of a single operation.

The compiler’s obligation is independent of all of this. Even on x86, where the hardware would not reorder a store above a later store, the optimiser might, because the C abstract machine permits it for non-atomic data. A release store is a compiler barrier in the direction of the release, and it must be treated as such regardless of the target’s hardware model. This is the interaction described in Compiler Reordering and the Contract It Must Not Break.

Acquire/release versus sequentially consistent atomics

C++ offers a stronger option: memory_order_seq_cst. It provides everything acquire/release provides, plus a single total order over all sequentially consistent operations, which acquire/release does not. The difference is observable in a specific family of tests, and it is worth being precise about what the extra strength buys.

Consider two threads, each storing to its own variable with release and loading the other’s with acquire, and a third thread observing both. With acquire/release, the two stores are ordered by per-location coherence but not against each other globally, so a reader can see them in different orders from different threads. With sequentially consistent atomics, all such operations participate in one total order and every thread agrees on it.

The cost of that extra order is real on weakly ordered machines: a sequentially consistent store typically requires a full fence before or after it, and a sequentially consistent load may require one after. On x86 the cost is small because the hardware model is nearly there already, which is why x86 code frequently gets away with the stronger ordering. Code written that way and moved to Arm pays for it.

The decision rule is mechanical. If the algorithm relies on all threads agreeing on the order of two atomic operations to different variables, it needs sequential consistency. If it relies only on publication and consumption through matched pairs, acquire/release suffices. Most lock-free data structures built on a single atomic pointer fall in the second category; most algorithms that establish a global phase or a shared epoch fall in the first.

Fences and the mixed-strength trap

atomic_thread_fence with release or acquire ordering is a different tool from a release store. A release fence orders the operations around it, but it does not itself carry a value or an address. A release store orders prior operations against that one store. The placement matters, and getting it wrong produces a classic bug:

// Wrong: the fence is after the store it was meant to order
atomic_store_explicit(&flag, 1, memory_order_relaxed);
atomic_thread_fence(memory_order_release);   // too late for the store above

// Right: the release is attached to the store
atomic_store_explicit(&flag, 1, memory_order_release);

A release fence orders operations that precede it against operations that follow it. A relaxed store placed before the fence is not ordered by it with respect to later operations, and a reader that acquires the flag has no edge to the data. The standard does define a fence-then-relaxed-store combination that establishes synchronisation when the reader acquires on that store, but the condition is narrow enough that attaching the ordering to the store itself is the safer habit. The rule of thumb is that the ordering belongs on the operation that establishes the edge.

A second trap is mixing strengths across a pair. An acquire load that reads from a relaxed store does not establish a happens-before edge, no matter how strong the load is. The synchronises-with relation needs a release on the writer side. A reader-side acquire paired with a writer-side relaxed store gives the reader ordering among its own later accesses but no guarantee about the writer’s earlier ones.

Where the constraint stops

Neither acquire nor release orders anything against an operation of the same kind in the opposite thread without a matching edge. A release store in thread 1 and a release store in thread 2 are unordered with respect to each other; only coherence constrains them, and coherence only constrains same-address writes. Two threads releasing to different flags produce no ordering at all between the data each protects, which is why a protocol that must establish a global order needs either sequentially consistent operations or an explicit fence protocol.

The constraint is also local to the operations the language model recognises. An acquire load orders later accesses in the same thread; it does not retroactively order accesses in another thread, and it does not order accesses that the compiler cannot see, such as those performed by a device through DMA. Device-visible ordering is a separate problem with its own rules, treated in Ordering Across the Device Boundary: DMA, MMIO, and Posted Writes.

The reason acquire/release is the default in modern code is that it matches the shape of the synchronisation that actually exists. Locks, reference counts, single-producer queues, and read-copy-update all publish through a single pointer or counter, and all need one-directional ordering at that point. Paying for a full fence at each of those sites buys nothing, and the pair buys exactly the ordering the pattern consumes.

References

  1. C++ Concurrency in Action — Anthony Williams, Manning Publications
  2. Foundations of the C++ Concurrency Memory Model — Hans-J. Boehm and Sarita V. Adve, ACM PLDI 2008
  3. ISO/IEC 14882:2011 — Programming Languages: C++ — ISO/IEC
  4. Arm Architecture Reference Manual for A-profile architecture — Arm Limited