07. Atomics and the memory model¶
Time: ~4 hours Prerequisites: 02, 03
1. In plain words¶
An atomic variable is one that every thread sees as a single, indivisible value: no torn reads, no lost updates on increment, and, critically, a way to say "when you see this value, you are also guaranteed to see everything I wrote before I stored it". That last part is the memory model, and it is the part most engineers get wrong.
The mental model that works is message passing. Think of a store with release semantics as
sealing an envelope: everything the sender wrote before sealing goes into the envelope. A load
with acquire semantics that reads that value is opening the envelope: everything in it is now
visible to the reader. Nothing is promised about writes that happened after the seal, and nothing
is promised to a reader who did not open the envelope. Relaxed atomics are postcards with no
envelope: the value arrives intact, but nothing else is promised about what else you can see.
Sequentially consistent atomics are envelopes that are also stamped by a single global post office
so everyone agrees on the order all envelopes were sent.
You have used InterlockedIncrement and InterlockedCompareExchange. On Windows x86 those are
full barriers (seq_cst). This module explains what you were getting for free, what it costs on ARM,
and how to ask for exactly as much ordering as you need.
2. The real thing¶
2.1 std::atomic<T>¶
#include <atomic>
std::atomic<int> counter{0};
std::atomic<bool> ready{false};
std::atomic<Node*> head{nullptr};
T must be trivially copyable. Operations:
| Operation | Meaning |
|---|---|
load(order) / operator T |
read |
store(v, order) / operator= |
write |
exchange(v, order) |
write, return old |
compare_exchange_weak(expected&, desired, ...) |
CAS, may fail spuriously |
compare_exchange_strong(expected&, desired, ...) |
CAS, fails only if value differs |
fetch_add / fetch_sub / fetch_and / fetch_or / fetch_xor |
read-modify-write, return old (integral and pointer types) |
operator++ / -- / += ... |
same, seq_cst |
is_lock_free() / is_always_lock_free |
does this use a hardware instruction or a hidden lock? |
is_lock_free() is true for 1, 2, 4, 8 byte types on x86-64 and usually for 16-byte with
-mcx16 (cmpxchg16b). For a 24-byte struct it is false: libatomic implements it with a hidden
mutex (a hash of spinlocks keyed by address). A non-lock-free atomic is still correct, just not
usable in signal handlers or where you were promised no blocking. static_assert(std::atomic<T>::
is_always_lock_free) when you depend on it.
2.2 Compare-and-swap¶
Atomically: if the current value equals expected, write desired and return true. Otherwise
write the current value into expected and return false. This is the universal building block;
every lock-free structure is a CAS loop:
int old = counter.load();
while (!counter.compare_exchange_weak(old, old * 2)) {
// old has been updated to the current value; loop recomputes desired
}
compare_exchange_weak may return false even when the value equals expected (on LL/SC
architectures like ARM, the store-conditional can fail because of an unrelated cache event). Use
weak inside a loop (it is the natural fit and cheaper on ARM), strong when a spurious failure
would make you take a wrong branch and you are not looping.
On x86 both compile to lock cmpxchg. On ARMv8 weak is ldaxr / stlxr once; strong is the
same in a retry loop.
2.3 The six memory orders¶
Forget consume; no compiler implements it as specified (they promote it to acquire) and it is
being redesigned. The five that matter:
| Order | Used on | Promise |
|---|---|---|
relaxed |
any | atomicity only. No ordering with other memory accesses. |
release |
store, RMW | every write before this store (in this thread) is visible to a thread that acquire-loads the stored value. "Seal the envelope." |
acquire |
load, RMW | every read or write after this load (in this thread) sees what the releasing thread wrote before its store. "Open the envelope." |
acq_rel |
RMW | both: the RMW is an acquire for the load half and a release for the store half. For passing the envelope along a chain. |
seq_cst |
any | acquire and/or release, plus a single total order of all seq_cst operations that every thread agrees on. Default. |
The precise rule, which you should be able to state in an interview:
If thread A performs a release store to atomic
xand thread B performs an acquire load ofxthat reads the value A stored (or a later value inx's modification order written by a release sequence), then A's release synchronizes-with B's acquire, and everything sequenced-before the release in A happens-before everything sequenced-after the acquire in B.
"Sequenced before" is the within-thread program order. "Happens before" is the cross-thread guarantee. A write that happens-before a read is visible to that read. That is the whole memory model in one sentence; the rest is consequences.
2.4 The canonical example: publish a result¶
int data = 0; // plain, non-atomic
std::atomic<bool> ready{false};
// producer // consumer
data = 42; while (!ready.load(std::memory_order_acquire)) {}
ready.store(true, std::memory_order_release); assert(data == 42); // guaranteed
Why it is correct: data = 42 is sequenced-before the release store. The acquire load reads
true, which the release stored, so the release synchronizes-with the acquire. Therefore
data = 42 happens-before assert(data == 42). The compiler may not move data = 42 below the
release store, nor the data read above the acquire load, and it must emit whatever CPU
instructions make the hardware honour that.
With relaxed on both, there is no synchronizes-with. The assert can fail: the compiler may
reorder, or the CPU may (on ARM). The program is not a data race on ready (it is atomic) but it
is a data race on data, because the write and read of data are unordered. Relaxed atomics
do not create happens-before.
2.5 Relaxed: when it is enough¶
Relaxed guarantees: the operation is atomic, and all threads agree on the modification order of that one variable (you never see a counter go 5, 7, 6). That is exactly what a statistics counter needs:
Also enough for: a "stop" flag where the consumer only needs to eventually see it and does not read other data based on it; a sequence number generator; a reference count increment (you already hold a reference, so you are not publishing anything).
Not enough for: any flag that says "the data is ready", any pointer that is published for others
to dereference, a reference count decrement that leads to delete (the deleter must see every
write from every other previous owner: fetch_sub(1, acq_rel) or release plus an acquire
fence before the delete; this is what shared_ptr does).
2.6 What seq_cst adds¶
Acquire/release gives you pairwise agreement: A and B agree about A's envelope. It does not give you a global order of unrelated envelopes. The store-buffering test from module 02:
std::atomic<int> x{0}, y{0};
// thread 1 // thread 2
x.store(1, release); y.store(1, release);
int r1 = y.load(acquire); int r2 = x.load(acquire);
// can r1 == 0 && r2 == 0? With acquire/release: YES. With seq_cst: NO.
With acquire/release each thread's store and load are to different variables and nothing synchronizes (no load read the other's store), so no happens-before edge exists and both can read
- With
seq_cst, all four operations are in one total order; whichever store is first in it is visible to the other thread's load. Dekker's and Peterson's mutual exclusion algorithms depend on this, which is why they fail with acquire/release.
Rule: use seq_cst (the default) unless you have measured a cost and can write down the
acquire/release argument for why the weaker order is correct. On x86 the only extra cost of
seq_cst is on stores (an mfence or xchg instead of a plain mov); loads are free either way.
On ARM both loads and stores pay.
2.7 Fences¶
std::atomic_thread_fence(order) is a standalone barrier. A release fence followed (later in
program order) by a relaxed store makes that store act as a release; a relaxed load followed by
an acquire fence makes that load act as an acquire. Use case: you have many relaxed operations and
want one barrier instead of one per operation:
// producer: fill 1000 slots relaxed, then one fence, then publish
for (...) slot[i].store(v, relaxed);
std::atomic_thread_fence(std::memory_order_release);
published.store(true, std::memory_order_relaxed);
// consumer
while (!published.load(relaxed)) {}
std::atomic_thread_fence(std::memory_order_acquire);
// all slot[i] visible
std::atomic_signal_fence is the compiler-only barrier (no CPU instruction) for ordering with a
signal handler on the same thread. The old asm volatile("" ::: "memory") is the GCC equivalent.
2.8 Why volatile is not atomic¶
Covered in module 02; the one-line version: volatile constrains the compiler's treatment of that
variable's accesses and nothing else. No atomicity for multi-word types, no CPU fence, no ordering
with other variables, no happens-before. MSVC's /volatile:ms extension gives acquire/release on
x86 and ARM, which is why old Win32 code with volatile LONG flags works on MSVC and breaks on
GCC. Replace with std::atomic.
2.9 x86 vs ARM¶
| x86-64 (TSO) | ARMv8 (weakly ordered) | |
|---|---|---|
| relaxed load / store | mov |
ldr / str |
| acquire load | mov (free) |
ldar |
| release store | mov (free) |
stlr |
| seq_cst load | mov (free) |
ldar |
| seq_cst store | xchg or mov; mfence (expensive) |
stlr (+ dmb on some compilers) |
| fetch_add any order | lock xadd (full barrier always) |
ldxr/stxr loop or ldadd (ARMv8.1) with ordering flags |
| CAS any order | lock cmpxchg (full barrier) |
ldaxr/stlxr loop or cas |
| full fence | mfence |
dmb ish |
Consequences: on x86 every RMW is already a full barrier so memory orders on fetch_add change
nothing in the generated code; the only place you see a difference is seq_cst stores. On ARM every
order choice changes the code and the cost. Code that is subtly wrong (relaxed where acquire was
needed) passes on x86 and fails on ARM. Test on ARM if you can (a Raspberry Pi, an AWS Graviton
instance, Apple silicon).
2.10 Mapping to Win32 Interlocked¶
| Win32 | C++ |
|---|---|
InterlockedIncrement(&v) |
v.fetch_add(1) + 1 (seq_cst) |
InterlockedExchange(&v, x) |
v.exchange(x) |
InterlockedCompareExchange(&v, desired, expected) |
v.compare_exchange_strong(expected, desired) (note argument order differs; Win32 returns the old value, C++ returns bool and updates expected) |
InterlockedExchangeAdd |
fetch_add |
InterlockedOr / And |
fetch_or / fetch_and |
InterlockedIncrementAcquire / Release |
fetch_add(1, acquire / release) |
InterlockedIncrementNoFence |
fetch_add(1, relaxed) |
MemoryBarrier() |
atomic_thread_fence(seq_cst) |
_ReadWriteBarrier() |
atomic_signal_fence(seq_cst) (compiler only) |
volatile with /volatile:ms |
acquire load / release store |
All un-suffixed Interlocked functions are full barriers. The Acquire, Release and NoFence
suffixed variants exist for ARM Windows where the difference matters.
3. Code¶
File: code/m07_atomic_counter_orders.cpp — counter correctness and the cost of each order.
#include <atomic>
#include <thread>
#include <vector>
#include <iostream>
#include <chrono>
template <class F>
double time_ms(F f) {
auto t0 = std::chrono::steady_clock::now();
f();
return std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
}
std::atomic<long> counter{0};
void bump(std::memory_order mo, int n) {
for (int i = 0; i < n; ++i) counter.fetch_add(1, mo); // WHY: on x86 all orders emit lock xadd
}
std::atomic<long> seq_store_target{0};
void seq_stores(std::memory_order mo, int n) {
for (int i = 0; i < n; ++i) seq_store_target.store(i, mo); // WHY: here x86 differs: mov vs xchg
}
int main() {
const int threads = 4, per = 2'000'000;
for (auto mo : {std::memory_order_relaxed, std::memory_order_acq_rel, std::memory_order_seq_cst}) {
counter.store(0);
double ms = time_ms([&] {
std::vector<std::thread> ts;
for (int t = 0; t < threads; ++t) ts.emplace_back(bump, mo, per);
for (auto& t : ts) t.join();
});
std::cout << "fetch_add order " << static_cast<int>(mo) << ": counter=" << counter.load()
<< " (expected " << static_cast<long>(threads) * per << ") " << ms << " ms\n";
}
for (auto mo : {std::memory_order_relaxed, std::memory_order_release, std::memory_order_seq_cst}) {
double ms = time_ms([&] { seq_stores(mo, 50'000'000); });
std::cout << "single-thread store order " << static_cast<int>(mo) << ": " << ms << " ms\n";
}
static_assert(std::atomic<long>::is_always_lock_free, "need lock-free long");
struct Big { long a, b, c; };
std::atomic<Big> big{};
std::cout << "atomic<24-byte struct> lock free: " << (big.is_lock_free() ? "yes" : "no") << '\n';
return 0;
}
File: code/m07_message_passing.cpp — the publish pattern, and the store-buffering litmus test
showing acquire/release vs seq_cst.
#include <atomic>
#include <thread>
#include <iostream>
#include <vector>
#include <cassert>
// Part 1: publish non-atomic data through an atomic flag with release/acquire.
struct Payload { int a; int b; long c; };
Payload payload{}; // plain data, written by producer before publish
std::atomic<bool> ready{false};
void producer() {
payload = Payload{1, 2, 3}; // sequenced-before the release store
ready.store(true, std::memory_order_release); // seal the envelope
}
void consumer(long& out) {
while (!ready.load(std::memory_order_acquire)) {} // open the envelope
out = payload.a + payload.b + payload.c; // guaranteed 6; no data race
}
// Part 2: store buffering. Count how often both threads read 0.
template <std::memory_order StoreMO, std::memory_order LoadMO>
long store_buffering(int iterations) {
long both_zero = 0;
for (int it = 0; it < iterations; ++it) {
std::atomic<int> x{0}, y{0};
std::atomic<int> start{0};
int r1 = -1, r2 = -1;
std::thread t1([&] {
start.fetch_add(1); while (start.load() < 2) {} // WHY barrier: both start together
x.store(1, StoreMO);
r1 = y.load(LoadMO);
});
std::thread t2([&] {
start.fetch_add(1); while (start.load() < 2) {}
y.store(1, StoreMO);
r2 = x.load(LoadMO);
});
t1.join(); t2.join();
if (r1 == 0 && r2 == 0) ++both_zero;
}
return both_zero;
}
int main() {
long sum = 0;
std::thread c(consumer, std::ref(sum));
std::thread p(producer);
p.join(); c.join();
std::cout << "published sum: " << sum << '\n';
assert(sum == 6);
const int iters = 20000; // thread creation dominates; keep modest
std::cout << "store-buffering (release/acquire): both-zero "
<< store_buffering<std::memory_order_release, std::memory_order_acquire>(iters)
<< " / " << iters << " (non-zero is legal and happens on x86)\n";
std::cout << "store-buffering (seq_cst): both-zero "
<< store_buffering<std::memory_order_seq_cst, std::memory_order_seq_cst>(iters)
<< " / " << iters << " (must be 0)\n";
return 0;
}
The first number is often small but non-zero on a multi-core x86 (you need the two stores to be in flight at the same moment). Thread creation per iteration makes the window narrow; exercise 3 asks you to improve the harness. The second number is always 0.
4. Under the hood (senior-level)¶
What the compiler emits (x86-64, GCC 13, -O2)¶
ready.store(true, std::memory_order_release); // mov BYTE PTR ready[rip], 1
ready.store(true, std::memory_order_seq_cst); // mov eax,1 ; xchg al, BYTE PTR ready[rip]
ready.load(std::memory_order_acquire); // movzx eax, BYTE PTR ready[rip]
counter.fetch_add(1, std::memory_order_relaxed); // lock add QWORD PTR counter[rip], 1
The release store is a plain mov because x86 TSO already forbids a store from passing an earlier
store or load. The compiler's job on x86 is mostly to not reorder across the atomic; the hardware
does the rest. The seq_cst store needs xchg (which is implicitly locked and therefore a full
fence) to prevent a later load from being satisfied before this store leaves the store buffer:
the store-buffering case.
On ARMv8 the same four lines are stlr, stlr, ldar, and an ldaxr/add/stlxr loop (or
ldadd on v8.1). Every ordering decision becomes an instruction choice.
Cache coherence makes atomics possible¶
All modern multi-core CPUs are cache coherent: at any instant, a given cache line has one agreed value across all cores (MESI protocol, module 10). Coherence is why a relaxed store is eventually seen by every other core and why all cores agree on the modification order of a single variable. What coherence does not give you is ordering between different cache lines. Core A may see line X's new value before line Y's new value while core B sees the reverse. Memory orders are about cross-line ordering; coherence alone handles single-variable atomicity.
The store buffer and the invalidation queue¶
Two hardware structures cause reordering. The store buffer holds stores waiting for cache-line
ownership; later loads from the same core can complete before earlier stores drain (store-load
reordering, allowed on x86). The invalidation queue on each core holds "your copy of line L is
stale" messages that the core acknowledges before processing; a load can hit a stale line still in
cache (allowed on ARM/POWER, not x86). A release store on ARM (stlr) waits for prior stores to
drain; an acquire load (ldar) processes the invalidation queue before loading. On x86 the
hardware does both implicitly for ordinary loads and stores, which is why only the store-load case
needs mfence/xchg.
lock prefix cost¶
A lock-prefixed instruction must obtain the line in Exclusive state, perform the RMW with the line
pinned, and (because it is a full fence) drain the store buffer. Uncontended, on a line already in
L1: roughly 20 cycles (typical). Contended across cores: the line bounces, 100+ ns per operation
(typical), and throughput falls as you add cores. That is why fetch_add on one shared counter
from 16 threads is slower than from 1 thread, and why module 10 uses per-thread counters.
Modification order and release sequences¶
Each atomic variable has a single modification order all threads agree on. If A release-stores
value 1, and then B does a relaxed fetch_add producing 2, and C acquire-loads 2, C still
synchronizes with A's release: the RMW extends A's release sequence. This is what makes a
reference count work: the thread that decrements to zero acquires the releases of all previous
decrementers, not just the last one.
Why the standard, not the hardware, is the contract¶
Writing to the hardware model ("x86 is TSO, so relaxed is fine for this flag") is wrong even if the
generated code would be right, because the compiler is bound only by the standard. A relaxed
store gives the compiler permission to move data = 42 below it. GCC does this. The hardware
never gets a chance to save you.
5. In production¶
- Statistics counters. Replace
g_requests++withstd::atomic<long>andfetch_add(1, relaxed). Correct, portable, and cheap when read rarely. If 32 workers hammer it, it becomes a contended line; module 10 fixes that with per-thread counters. - Shutdown flag.
std::atomic<bool> stop. Workersload(relaxed)in their loop condition is fine if they only exit on it. If they also read "final config" or "drain target" that the stopper wrote just before setting the flag, it must berelease/acquire. When unsure, use the default seq_cst; a shutdown flag is read once per loop and the cost is irrelevant. - Published pointers. A config or cache snapshot swapped by
ptr.store(new_table, release)and read byptr.load(acquire). The acquire is not optional: without it the reader may see the pointer but not the table's contents (on ARM) or the compiler may hoist reads of the table above the load. Module 11 builds this. - Double-checked locking. The textbook broken pattern becomes correct with an acquire load on
the fast path and a release store after construction, or you use
std::call_oncewhich does it for you (module 11). - Porting
volatile LONGWin32 flags. Each one is a bug on GCC. Audit, replace withstd::atomic, choose the order by asking "does anyone read other data based on this value?" If yes, release/acquire. If no, relaxed. - 3 AM symptom: a flag-based handshake works on the x86 build and the consumer reads garbage on the new ARM box. Nine times out of ten a relaxed store that should have been release. TSan (which understands memory orders) reports this on x86 even though x86 hides it at runtime.
6. Try it¶
- (easy) Build and run
m07_atomic_counter_orders.cpp. Confirm all three orders give the correct count. Compare the store timings: relaxed and release should be similar, seq_cst slower. Dump the assembly (-S) and find thexchg. Done when you can point to it. - (easy) In
m07_message_passing.cpp, change the producer's store torelaxedand the consumer's load torelaxed. Build with-fsanitize=thread. TSan reports a data race onpayload. Restore release/acquire; TSan is silent. Done when you can explain why TSan considers the relaxed version a race even though x86 would run it correctly. - (medium) Improve the store-buffering harness: two long-lived threads with a shared atomic "round" counter, each iteration both threads spin until the round increments, then do their store/load, then signal completion. Run 10 million rounds. Report the both-zero count for relaxed, release/acquire, and seq_cst. Done when relaxed and acq/rel show non-zero and seq_cst shows exactly zero.
- (medium) Implement a reference-counted
struct Shared { std::atomic<int> refs; Payload p; }withacquire_ref()(relaxed increment) andrelease_ref()(decrement; if it hits zero, delete). Choose the memory order for the decrement and write a comment deriving whyacq_rel(orreleaseplus an acquire fence) is necessary and relaxed is not. Done when the comment mentions "the deleter must see every other thread's writes top". - (hard) Write Peterson's mutual-exclusion algorithm for two threads with atomics. Use seq_cst and verify two threads incrementing a plain shared counter 1 million times each give exactly 2 million. Then change to release/acquire and observe (on x86 you may need many runs, on ARM it fails quickly) that mutual exclusion breaks. Explain in terms of the store-buffering test which reorder breaks it. Done when the explanation names the two stores and two loads involved.
7. Interview questions¶
Q: Explain acquire and release. A release store seals everything the storing thread wrote before it into the stored value; an acquire load that reads that value unseals it, so everything the storer did before the release is visible to the loader after the acquire. Formally the release synchronizes-with the acquire and sequenced-before plus synchronizes-with gives happens-before. Nothing is promised to threads that do not load the value, and relaxed operations do not create this edge.
Q: What does seq_cst add over acquire/release? A single total order over all seq_cst operations that every thread agrees on. Acquire/release only orders the pair that actually communicated; two threads each storing to their own flag and loading the other's can both read the old value. Under seq_cst one store is first in the global order and the other thread's load must see it. Dekker's and Peterson's algorithms need this.
Q: When is memory_order_relaxed correct?
When you need atomicity and nothing else: a statistics counter nobody makes decisions from, a
sequence generator, a reference count increment where you already own a reference, a stop flag
checked in a loop where no other data is read based on it. The moment another thread reads other
memory because it saw the value, you need release on the writer and acquire on the reader.
Q: Why does std::shared_ptr use acq_rel (or release plus acquire fence) on the decrement?
The thread whose decrement reaches zero deletes the object and must see every write every other
owner made to it before releasing their reference. Each decrement releases that thread's writes;
the final decrement must acquire all of them. Relaxed would allow the deleter to free memory that
another core's writes have not yet reached.
Q: What is the difference between compare_exchange_weak and strong?
Weak may fail spuriously, returning false even when the value matched, because on LL/SC
architectures the store-conditional can fail for reasons unrelated to the value. Use weak in a
retry loop (natural fit, cheaper on ARM), strong when a false failure would send you down a wrong
path and you are not looping. On x86 both are lock cmpxchg.
Q: Why is volatile not a substitute for std::atomic?
It only constrains the compiler's handling of that variable's accesses: no elision, no caching in
registers, no reordering with other volatiles. It gives no atomicity for multi-word types, emits
no CPU fences, imposes no order relative to non-volatile accesses, and creates no happens-before,
so the program is still UB under the standard. MSVC's /volatile:ms adds acquire/release as an
extension, which is why some Win32 code gets away with it.
Q: Why does the same memory-order bug pass on x86 and fail on ARM?
x86 is TSO: hardware never reorders stores with stores or loads with loads, so acquire loads and
release stores are plain movs and relaxed code often behaves as if it were acquire/release.
ARM reorders almost everything unless told not to, so a missing ldar/stlr lets the hardware
expose the reorder. The compiler can reorder on both, so even on x86 the relaxed version is a real
bug that happens to be hidden most of the time.
Q: What does lock xadd cost and why does a shared counter get slower with more threads?
The lock prefix requires the core to hold the cache line in Exclusive state for the duration and
acts as a full fence. Uncontended on a local line it is tens of cycles; contended, the line
migrates between cores on every increment at 100 ns or more each, so total throughput drops as
cores are added. Per-thread counters that are summed on read avoid the shared line.
8. Recap¶
std::atomic<T>: indivisible load, store, exchange, CAS, fetch-ops;is_lock_freetells you whether it is a hardware instruction or a hidden lock.- Release store + acquire load that reads it = synchronizes-with; sequenced-before plus that = happens-before; happens-before means visible.
- Relaxed is atomicity only, no ordering; fine for counters and simple flags, wrong for publishing data.
- seq_cst adds a single global order; needed when two threads each write their own flag and read the other's. It is the safe default.
acq_relfor read-modify-write that passes ownership along a chain;shared_ptr's decrement.- Fences let one barrier cover many relaxed operations.
volatileis not atomic; MSVC's extension is why Win32 code sometimes survives.- x86: acquire/release are free, seq_cst stores cost an
xchg, RMWs are always full barriers. ARM: every order choice is a different instruction. - Interlocked* are seq_cst;
Acquire/Release/NoFencesuffixes map to the C++ orders. - The compiler is bound by the standard, not by your CPU; write to the standard.