Atomics and the Memory Model

Acquire and Release Semantics

Acquire and release are the two halves of the single most important synchronization relationship in the C++ memory model. They really should never be used alone; otherwise, it's almost certainly a bug.

Together they provide the standard means of publishing data from one thread and safely reading it in another.

A release operation prevents writes earlier in the same thread from marching past it. An acquire operation prevents reads later in the same thread from marching before it.

When an acquire load reads the value written by a release store, a synchronizes with edge appears; the data written before the release becomes visible to the thread performing the acquire.

It is that single edge which turns an ordinary write into safely published data.

#include <atomic>
#include <iostream>
#include <thread>

int payload = 0;
std::atomic<bool> ready{false};

void producer()
{
    payload = 42;                                   // 1. ordinary write
    ready.store(true, std::memory_order_release);   // 2. release
}

void consumer()
{
    while (!ready.load(std::memory_order_acquire))  // 3. acquire
    {
    }

    std::cout << payload << '\n';                   // 4. safe read
}

int main()
{
    std::jthread t1{producer};
    std::jthread t2{consumer};
}

Consider the four numbered steps. Statement 1 precedes statement 2 in the producer, and statement 3 precedes statement 4 in the consumer.

The synchronization between statements 2 and 3 occurs because the acquire load witnesses the value written by the release store; consequently, statement 1 must precede statement 4 and so the read of payload will be a data race free read that sees the value 42.

I myself use this pattern perhaps more than any other synchronization primitive, since it is inexpensive on modern hardware, and relatively easy to reason about once you learn to look for the pair.

If we change the load to std::memory_order_relaxed above, then undefined behavior results. The release still prevents the write to payload from moving after the store, but in the absence of an acquire, there is no synchronizes with edge, and it is legally possible for the consumer to read payload before the write has become visible to it.

This is a data race.

If we change the store to std::memory_order_relaxed, we have the disaster from the other side; the acquire has nothing to synchronize with. Both sides are necessary!

A release operation heads a release sequence: any later read modify write operation on the same atomic, even if it occurs in a different thread, can extend the sequence. An acquire that observes a value written within a release sequence will observe everything that happened before the original release operation.

This is useful in only a handful of advanced lock free algorithms, and for the most part should never enter an engineer’s conscious reasoning; if you are utilizing a release sequence you probably have overcomplicated your protocol.

In C++11, std::memory_order_acq_rel performs both acquire and release operations in one step when the same operation also reads and writes.

#include <atomic>

std::atomic<int> state{0};

bool try_claim()
{
    int expected = 0;

    return state.compare_exchange_strong(
        expected,
        1,
        std::memory_order_acq_rel
    );
}

Success in the compare and exchange operations that use this memory order results in both acquire and release semantics. Failure performs a load using the failure memory order.

When only one memory order is provided, the failure order corresponding to std::memory_order_acq_rel is std::memory_order_acquire. This behavior is natural for many lock free protocol state transitions.

  • Using std::memory_order_release with a load or std::memory_order_acquire with a store. The name of the order is directional.

  • Publishing many related values with separate release stores and separate acquire reads. It is possible to observe a torn value combination that has never existed.

  • Forgetting that a successful acquire release compare and exchange is a linearization point. Operations that other threads rely on seeing need to complete before that acquire release operation.

0 comments

No comments yet. Be the first.

Add a comment

About CppLearn

CppLearn is an interactive, free reference for modern C++. In each lesson, you'll find out how the language works, rather than just lines of code that you can copy and paste. We cover concepts starting from beginner lessons on fundamentals and proceed to templates, move semantics, smart pointers, and areas that usually trap or scare C++ learners.