75 / 163 · C++20 · 13 min
Memory order: relaxed, acquire-release, and happens-before
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
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
storeorwaitto relaxed removes the required synchronization. The ordinarypayloadread then has no correctness guarantee; it is not merely an occasional stale value. atomic::waitmay 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 && ./exampleExpected 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
- cppreference: memory_order modification order and release-acquire
- cppreference: atomic::wait value checks and ABA notes
- C++ working draft: [intro.races] happens-before and synchronizes-with
Drafts and official chapters change. The version mark is only the example’s minimum.