arthrop0d

Brief articles on varied topics.

Acquire and Release Memory Ordering

concurrencycomputer science

Acquire and release memory ordering are rules for publishing data between concurrent threads: a release operation makes earlier writes available to a thread that performs a matching acquire operation. Together, they establish a safe handoff without requiring one global order for every atomic operation.

The problem they solve

Modern compilers and processors reorder independent operations when doing so preserves the behavior of single-threaded code. Each processor may also keep writes in store buffers or caches before other processors observe them. As a result, source-code order alone is not a reliable communication protocol between threads.

Suppose one thread prepares an object and then sets a flag:

payload = 42;
ready = true;

Another thread might check ready and then read payload. Without synchronization, the reader can observe the flag but still fail to observe the initialized payload. In C++, if ready is non-atomic, the program has a data race. If ready is atomic but both operations use only relaxed ordering, the flag itself is safe to access, but it does not necessarily order the payload write before the payload read.

Acquire and release provide the missing relationship.

Release publishes; acquire receives

A release operation is an atomic operation that prevents earlier memory operations in the same thread from being moved after it, as far as the synchronization rules are concerned. It is the publishing point.

An acquire operation is an atomic operation that prevents later memory operations in the same thread from being moved before it. It is the receiving point.

The usual pattern is a release store by the producer and an acquire load by the consumer:

#include <atomic>

struct Result {
    int value;
};

Result result;
std::atomic<bool> ready{false};

// Producer thread
result.value = 42;
ready.store(true, std::memory_order_release);

// Consumer thread
if (ready.load(std::memory_order_acquire)) {
    use(result.value);
}

If the acquire load observes the true value stored by the release store, the write to result.value happens-before the read of result.value. “Happens-before” is the language’s formal guarantee that one operation is ordered before another and that its effects must be visible to it. The non-atomic result.value is safe here because the reader does not access it until after the handoff, and the producer does not modify it concurrently afterward.

The atomic flag carries the synchronization signal; it does not have to contain the data itself. The data can be a large object, while the flag is just a small state transition saying, in effect, “the writes before this point are ready for you.”

The important condition is not merely that both threads use the same atomic variable. The acquire must observe the value published by the release operation, or an update that properly carries that release forward.

What the orderings do not mean

A release operation does not flush every cache in the machine, and acquire does not mean “reload all memory.” Those are implementation details. The guarantee is expressed in terms of which operations are ordered and what another thread is allowed to observe.

Acquire and release also do not make the atomic variable behave as though all threads agree on one universal timeline. They create a directed relationship across a particular communication path:

  1. The producer performs ordinary writes.
  2. The producer performs a release operation on an atomic signal.
  3. The consumer performs an acquire operation that observes that signal.
  4. The consumer can safely use the writes that preceded the release.

Other atomic variables may still be observed in different orders by different threads unless they are connected by additional synchronization.

This is weaker than sequential consistency, which gives sequentially consistent atomic operations one single global order that respects each thread’s own order. Sequential consistency is easier to reason about, but imposing that order on every atomic access can restrict compiler and processor optimizations and may require stronger machine instructions.

Why not use relaxed ordering?

A relaxed atomic operation guarantees that an individual atomic object is accessed atomically and participates in that object’s modification order. It is useful for counters, statistics, and other cases where no surrounding data must be published.

But relaxed ordering does not create the handoff above. This version is not enough to publish result.value:

ready.store(true, std::memory_order_relaxed);

if (ready.load(std::memory_order_relaxed)) {
    use(result.value); // No release/acquire publication guarantee
}

The consumer may observe the flag while lacking the required ordering for the payload. Replacing the flag with a non-atomic variable is worse: concurrent access to it can itself be a data race.

The practical boundary

Acquire and release are well suited to one-time initialization, producer-consumer queues, and state transitions such as “configuration loaded” or “work item ready.” They are not a general replacement for a lock. If a producer continues modifying an object while a consumer reads it, the accesses still need appropriate synchronization, such as a mutex or carefully designed atomic operations.

When an incident report says that a flag was “set before” a pointer or object was initialized, the missing question is whether that ordering exists in the language memory model. A release store paired with an acquire load can provide it. A source-code sequence, a relaxed atomic flag, or an assumption that cache coherence will eventually make everything visible cannot.

← All articles