Memory Systems · Memory Modeling

Release, Acquire, and the Synchronizes-With Edge

What an acquire load is forbidden to move above, what a release store is forbidden to move below, and why the edge is directional rather than a barrier.

A mutex built from a full fence before and after every critical section is correct and wasteful. The fence at the start of the section must prevent later reads from moving above it, and the fence at the end must prevent earlier writes from moving below it, but neither fence needs to do both jobs at both ends. Acquire and release are the two halves of that full fence, split so that each half pays only for the direction it needs.

The split is the reason the language memory model exposes four orderings rather than two. It is also the reason acquire and release are directional: an acquire load constrains what may move above it and nothing else, and a release store constrains what may move below it and nothing else.

The two half-fences

An acquire operation on a load has one effect: no read or write that appears after the load in program order may be reordered before it. The load itself may be delayed, and it may be reordered against operations that precede it. A release operation on a store has the mirror effect: no read or write that appears before the store may be reordered after it.

The asymmetry is not arbitrary. A release store is publishing data that was written earlier, so what matters is that the data is visible before the flag. An acquire load is consuming data that will be read later, so what matters is that the reads happen after the flag has been observed. Each operation orders the operations on one side of itself, and the pair of them together orders everything in between.

A useful way to hold the rule is to imagine the acquire as a wall on the load’s upper side and the release as a wall on the store’s lower side. Operations cannot cross the wall from the side the wall faces. Operations on the other side move freely.

#include <stdatomic.h>
#include <stdlib.h>

struct record { int value; int checksum; };
_Atomic(struct record *) slot;

void publish(int value) {
    struct record *r = malloc(sizeof *r);
    if (!r) return;
    /* Plain writes, sequenced before the release store below. */
    r->value = value;
    r->checksum = value ^ 0x2a;
    atomic_store_explicit(&slot, r, memory_order_release);
}

int consume(void) {
    struct record *r = atomic_load_explicit(&slot, memory_order_acquire);
    if (!r) return -1;
    /* The fields are visible because the load synchronised with the store. */
    return r->value == (r->checksum ^ 0x2a);
}

The writes to the record’s fields are not atomic and carry no ordering of their own. What makes them safe is that they are sequenced before the release store, the acquire load reads the value that store wrote, and the synchronizes-with edge plus the transitive closure puts the field writes before the field reads. A reader that skipped the acquire would have no such edge and could observe a record whose fields are not yet the ones publish wrote.

The edge is a value-dependent relation

A release store does not synchronize with every acquire load from the same object. It synchronizes only with an acquire load that reads the value written by that store, or a value written by an operation in the release sequence headed by it. This is why the relation is often described as being about a particular read.

The distinction shows up in code that reuses a flag. If thread A release-stores 1 to flag, thread B acquire-loads flag and sees 2 from an unrelated store that is not in A’s release sequence, there is no edge from A. B has no guarantee about A’s data. Code that assumes any acquire on the flag orders against any earlier release is wrong, and the error is invisible on a machine whose stores happen to drain in order.

The C++11 standard states the relation in terms of the release sequence: a release store, followed in the object’s modification order by any number of read-modify-write operations on that object, all form a chain, and an acquire load that reads a value written by any member of the chain synchronizes with the release store at the head. C++11 also admitted plain stores by the same thread into the chain; C++20 removed that clause, so the portable chain is the release store plus intervening read-modify-writes. This makes a release store followed by a series of fetch-adds usable as a publish, because read-modify-writes do not break the chain, while a plain store from a different thread does.

Consume, and why it was not usable

C++11 also defined memory_order_consume, intended to be cheaper than acquire: it would order only operations that carry a data dependency from the loaded value, not all later operations. On the architectures that motivated it, a dependency-ordered load needs no barrier at all, because the hardware already respects address and data dependencies.

The facility was never implementable as specified. Compilers could not reliably track which operations depended on a loaded value through the optimisations they perform — a value can be copied, transformed, and reconstituted in ways that destroy the syntactic dependency while preserving the semantic one, or the reverse. The committee’s response was to permit implementations to treat consume as acquire, and that is what mainstream compilers do. The ordering was formally discouraged by the C++17 committee and later deprecated outright, and the recommendation is to write acquire.

The hardware-level version of the idea survives, and it is one of the distinguishing features of weak architectures; ARMv8’s dependency ordering covers how a load can order dependent accesses without a barrier.

Release and acquire across fences

The standard also defines standalone fences, atomic_thread_fence, with release and acquire semantics. Their effect is not attached to an operation on a particular object; instead they combine with relaxed atomic operations to create synchronisation. A release fence followed by a relaxed store to a flag synchronizes with an acquire fence that is preceded by a relaxed load of the flag reading that store. This is the pattern used by implementations that need to publish several locations at once with a single fence rather than a release on each store.

The rules are subtle enough to be a common source of bugs. A release fence does not order accesses by itself: it must be sequenced before an atomic store to some object, an acquire fence must be sequenced after an atomic load of that same object, and the effect exists only when the load reads the value the store wrote.

What the pair does not give

Two properties that acquire and release are often assumed to have, and do not.

The first is sequential consistency. A release store and an acquire load create an edge, but they do not participate in a single total order over all such operations unless the ordering is memory_order_seq_cst. Two threads that each do a release store and an acquire load on different objects can disagree about which happened first, and the language permits it.

The second is mutual exclusion. A release store followed by an acquire load that reads it is a one-way publication. It does not stop a third thread from also reading the flag and proceeding, and it does not stop two threads from both acquiring the same flag if both read the same value. Mutual exclusion needs an atomic read-modify-write — an exchange or a compare-and-swap — that only one thread can win. This is why spinlocks are built on atomic_exchange or compare_exchange_weak with acquire semantics, not on load and store.

The implementation behind the edge

On a machine with a store buffer, a release store is implemented by draining the buffer or by a barrier that ensures prior stores are visible before the release store is. On such a machine, a release store is measurably more expensive than a relaxed store, and this is the cost the programmer accepts for the edge. An acquire load is typically a plain load with a barrier after it, or a load-acquire instruction that prevents subsequent memory operations from issuing early.

The cost asymmetry explains a design pattern that recurs in lock-free code: put the acquire on the reader, which is the common case and usually cheap, and put the release on the writer, which is rarer. The choice of which side to make seq_cst is a further cost decision, and the DRF-SC guarantee explains why the weaker pair is sufficient for any program whose conflicting accesses are all ordered by such edges.

References

  1. ISO/IEC 14882:2017 — Programming languages — C++ — ISO/IEC
  2. A Less Formal Explanation of the Proposed C++ Concurrency Memory Model — Hans-J. Boehm, ISO C++ committee paper
  3. ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC