C++ / a working model

75 / 163   ·   C++20   ·   13 min

Memory order: relaxed, acquire-release, and happens-before

Keep this sentence

Memory order describes how an atomic operation constrains surrounding accesses. relaxed keeps atomicity but does not publish ordinary data. An acquire that reads the corresponding release write is what establishes inter-thread synchronization. Correctness is proved by a happens-before chain, not by an execution order that merely looks stable on one machine.

In this lesson
  1. Happens-before is a proof relation, not wall-clock time
  2. A complete proof of one publication
  3. Choose an order that is strong enough and that you can explain
  4. Example
  5. Exercise

Happens-before is a proof relation, not wall-clock time

The prescribed evaluation order inside one thread is sequenced-before. A suitable inter-thread synchronization operation establishes synchronizes-with. Connecting those edges and taking their transitive closure is how you prove that a write happens-before a later read. Seeing thread A appear to finish first, or sleeping thread B for a while, does not create that relation.

Each atomic object has its own modification order, even when every access is relaxed. The modification orders of several objects do not therefore compose into one global order. Relaxed ordering fits independent counting that cares only about the atomic value. It does not fit using a flag alone to announce that an ordinary object has been initialized. A legal read of the flag does not make a neighboring data read legal.

A complete proof of one publication

In the example the producer writes the ordinary object payload, then release-stores to ready. The consumer acquire-waits until it observes ready change from false to true, then reads payload. The only store of true is that release, so an acquire that successfully observes the value synchronizes-with it. Write payload → release → acquire → read payload is a complete happens-before chain.

Therefore payload need not itself be an atomic type. After publication it must not be modified, and the object must remain alive. The code deliberately reads payload before join. Safety comes from the publication protocol, not from the later join.

C++20 atomic::wait checks the current value. A notify that happens first does not lose a state that has already changed. notify_one is how a waiter is awakened. It is not a memory barrier that publishes data.

Choose an order that is strong enough and that you can explain

Default seq_cst adds, beyond the corresponding acquire and release effects, a single total order on all seq_cst operations that the standard constrains. That is a good starting implementation, and it helps algorithms that reason across several atomic objects. It does not make arbitrary ordinary accesses safe by itself, and it does not fuse several operations into a transaction. acq_rel is the usual choice for a read-modify-write that both receives old state and publishes new state.

Do not mark every load acquire and every store release and then declare the program safe. A load must observe the relevant release write, or a value in a release sequence the rules allow. The example publishes once and does not reset ready to false. Reusing a buffer requires the consumer to confirm that the read has finished, so the producer's next write does not race with the current read. Before weakening to relaxed, name the synchronization edge you are deleting and prove that the remaining edges still suffice.

Pitfalls

  • Changing the example store or wait to relaxed removes the required synchronization. The ordinary payload read then has no correctness guarantee; it is not merely an occasional stale value.
  • atomic::wait may miss a value that changed to a new value and then back to the old one. A one-shot publication that does not reset avoids that ABA risk. Cyclic communication needs a separate design.

Run an example

Minimum C++20 · complete program · Download .cpp

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

struct Payload {
    int left = 0;
    int right = 0;
};

int main() {
    Payload payload;
    std::atomic<bool> ready{false};
    std::jthread producer([&] {
        payload.left = 6;
        payload.right = 7;
        ready.store(true, std::memory_order_release);
        ready.notify_one();
    });

    ready.wait(false, std::memory_order_acquire);
    const int result = payload.left * payload.right;
    assert(payload.left == 6 && payload.right == 7);
    assert(result == 42);
    producer.join();
    std::cout << result << '\n';
}

Compile locally

g++ -std=c++20 -Wall -Wextra -Wpedantic -pthread concurrency-memory-order.cpp -o example && ./example

Expected result

42

CHECK YOUR UNDERSTANDING

Close the answer. Explain it.

If the consumer first producer.join()s and then reads ready and payload with relaxed ordering, is that safe? Does it prove the original publication protocol can use relaxed ordering?

Show a reference answer

Reads after a successful join are safe: completion of the producer synchronizes with return from join, so the producer's payload writes happen-before the later reads, and there are no later writes. That does not prove the original protocol may use relaxed ordering, because adding join replaced the synchronization path. The original example requires the consumer to use the data before join, and that still needs the release/acquire publication chain.

Check the sources

Drafts and official chapters change. The version mark is only the example’s minimum.

Back to the catalog