CodeRigor

FREE LESSON

Atomics and the C++ memory model

Share data between threads without locks: atomic operations, compare-exchange loops, and the memory orders (relaxed, acquire/release, seq_cst) that decide what other threads are guaranteed to see.

A mutex makes threads take turns. Atomics let them cooperate without taking turns — but then you decide what each thread is guaranteed to see, and the language gives you very few guarantees for free. This lesson is about those guarantees: what a data race is, what atomic operations do, and what the memory orders (relaxed, acquire/release, seq_cst) promise.

Snippets here run under ThreadSanitizer (TSan), like the exercises. TSan tracks happens-before exactly as the standard defines it, so it reports a missing ordering even when the program happens to print the right answer.

1. Data races are undefined behaviour

A data race is two threads accessing the same memory location, at least one of them writing, with no happens-before relation between them and not both atomic. A data race is UB — not "might print a stale value", but "the compiler may assume it never happens":

C++
bool done = false;              // plain bool, written by another thread
while (!done) { }               // the compiler may hoist the load: loop forever

The fixes are a mutex, or making the shared variable std::atomic. Atomic operations never race with each other; the question is what other data they make safe to touch.

2. std::atomic

std::atomic<T> works for trivially copyable T. Integers, pointers and bool are lock-free on every mainstream platform; check with is_always_lock_free.

C++
#include <atomic>
#include <print>

int main() {
    std::atomic<int> n{10};
    int before = n.fetch_add(5);        // returns the OLD value
    int old = n.exchange(100);          // store and return the old value
    std::println("{} {} {}", before, old, n.load());
    std::println("lock-free int: {}", std::atomic<int>::is_always_lock_free);

    int plain = 0;
    std::atomic_ref<int> ref(plain);    // C++20: atomic ops on a plain object
    ref.fetch_add(1);                   // (while any atomic_ref is in use, only access it through atomic_refs)
    std::println("{}", plain);
}

Atomics aren't copyable (std::atomic<int> a = b; doesn't compile — copying would be a load plus a store, two operations, not one).

3. Read-modify-write and compare-exchange

fetch_add, fetch_sub, fetch_or, exchange are single indivisible read-modify-writes. Anything they can't express — a maximum, a saturating add, pushing onto a linked stack — is a compare-exchange loop:

C++
T expected = a.load();
do {
    desired = f(expected);                       // compute from what you saw
} while (!a.compare_exchange_weak(expected, desired));
// on failure, expected was overwritten with the current value: just retry

compare_exchange_weak may fail spuriously (on LL/SC architectures like AArch64 without LSE), and of course it fails when another thread changed the value first. That's why it lives in a loop. compare_exchange_strong never fails spuriously; use it for one-shot attempts whose failure means something.

Worked example: atomic maximum

C++
#include <atomic>
#include <cstdint>
#include <print>
#include <thread>
#include <vector>

void store_max(std::atomic<std::uint64_t>& m, std::uint64_t v) {
    std::uint64_t cur = m.load(std::memory_order_relaxed);
    // Stop as soon as the stored max is already >= v; otherwise try to install v.
    while (v > cur && !m.compare_exchange_weak(cur, v, std::memory_order_relaxed)) {
    }
}

int main() {
    std::atomic<std::uint64_t> m{0};
    {
        std::vector<std::jthread> ts;
        for (std::uint64_t t = 0; t < 4; ++t)
            ts.emplace_back([&, t] {
                for (std::uint64_t i = 0; i < 1000; ++i) store_max(m, i * 4 + t);
            });
    }   // jthreads join here
    std::println("max = {}", m.load());
}
Two threads race to raise the maximum; the loser re-reads and stops
Both read m == 5. Thread 2's CAS installs 9. Thread 1's CAS fails because m is no longer 5 — and cur is overwritten with 9, so v > cur (7 > 9) is false and the loop ends.
Two threads race to raise the maximum; the loser re-reads and stops
Both read m == 5. Thread 2's CAS installs 9. Thread 1's CAS fails because m is no longer 5 — and cur is overwritten with 9, so v > cur (7 > 9) is false and the loop ends.

relaxed is fine here: nobody uses the maximum to decide whether some other data is ready. The join at the end synchronizes the final read.

4. Memory orders

Every atomic operation takes a std::memory_order. It says nothing about the atomic itself (that is always consistent) — it says what other memory a thread is guaranteed to see.

Order Use it for Guarantees
relaxed counters, statistics, IDs atomicity only
release (stores) / acquire (loads) handing data from one thread to another everything written before the release store is visible after an acquire load that reads that store
acq_rel read-modify-writes that both receive and hand over (e.g. a lock-free stack's CAS) both
seq_cst (the default) when you need one global order of all such operations acquire/release + a single total order

Release/acquire: message passing

The pattern you'll use most: write the data, then release-store a flag; the reader acquire-loads the flag, then reads the data.

C++
#include <atomic>
#include <print>
#include <string>
#include <thread>

std::string message;                // plain data
std::atomic<bool> ready{false};     // the hand-off

int main() {
    std::jthread producer([] {
        message = "hello from the producer, long enough to live on the heap";
        ready.store(true, std::memory_order_release);   // publishes `message`
    });
    std::jthread consumer([] {
        while (!ready.load(std::memory_order_acquire)) { }  // synchronizes-with the store
        std::println("{}", message);                        // guaranteed to see it
    });
}

In words: the release store synchronizes with the acquire load that reads true, so the write to message happens before the read. Change either side to relaxed and TSan reports a data race on message — the counts might still look right on x86, which is exactly why this is dangerous (§5).

Message passing
Synchronizes-with (solid) gives happens-before (dashed): the write to message is visible to the read.
Message passing
Synchronizes-with (solid) gives happens-before (dashed): the write to message is visible to the read.

Two rules people miss:

  1. The acquire must read the value the release wrote (or a later value in that atomic's modification order). Acquire-loading some other atomic does nothing.
  2. Release orders what comes before it in program order. Writing the data after the release store isn't published.

Played on a weak (Arm-like) machine, where the hardware really does reorder: first with relaxed operations, then with release/acquire.

Relaxed message passing: the flag can overtake the data
Nothing orders the two stores or the two loads, so the consumer can read flag = 1 and still get the old data. On x86 (switch to TSO) the hardware happens to keep both pairs in order — the C++ program is still broken.
Relaxed message passing: the flag can overtake the data
Nothing orders the two stores or the two loads, so the consumer can read flag = 1 and still get the old data. On x86 (switch to TSO) the hardware happens to keep both pairs in order — the C++ program is still broken.
Release/acquire message passing: the stale read is gone
The release store can't drain before data's store, and nothing after the acquire load runs before it. Reading flag = 1 now guarantees data = 42.
Release/acquire message passing: the stale read is gone
The release store can't drain before data's store, and nothing after the acquire load runs before it. Reading flag = 1 now guarantees data = 42.

seq_cst and the store-buffer test

C++
// x = y = 0 initially
// Thread 1:            Thread 2:
x.store(1);             y.store(1);
r1 = y.load();          r2 = x.load();
The store-buffer test: each store waits in its core's buffer
Each core's store sits in its private store buffer while the core goes on to load the other variable from memory — still 0. Both loads can return 0 unless every operation is seq_cst (on x86 that costs an mfence or a locked instruction).
The store-buffer test: each store waits in its core's buffer
Each core's store sits in its private store buffer while the core goes on to load the other variable from memory — still 0. Both loads can return 0 unless every operation is seq_cst (on x86 that costs an mfence or a locked instruction).

With acquire/release (or relaxed) operations, r1 == 0 && r2 == 0 is allowed — each thread's store can still be in its core's store buffer when the other thread loads. With the default seq_cst on all four operations it is forbidden: there is a single order of all seq_cst operations that both threads agree on. You need this for "announce mine, then check yours" algorithms (Dekker/Peterson locks, some hazard-pointer schemes). Most code doesn't — but seq_cst is the default because it's the hardest to get wrong.

Store buffering with release/acquire: both loads can still read 0
Release only orders what comes before the store, acquire only what comes after the load — nothing stops a load from overtaking the earlier store. Allowed on x86 and on Arm; only SC forbids it.
Store buffering with release/acquire: both loads can still read 0
Release only orders what comes before the store, acquire only what comes after the load — nothing stops a load from overtaking the earlier store. Allowed on x86 and on Arm; only SC forbids it.
Store buffering with seq_cst: forbidden everywhere
A seq_cst load can't run while its thread's seq_cst store is still unseen — on Arm, ldar waits for the earlier stlr; on x86 the store is an xchg that drains the buffer. One of the loads must see the other thread's store.
Store buffering with seq_cst: forbidden everywhere
A seq_cst load can't run while its thread's seq_cst store is still unseen — on Arm, ldar waits for the earlier stlr; on x86 the store is an xchg that drains the buffer. One of the loads must see the other thread's store.

5. Why x86 hides your bugs

x86 is TSO (total store order): stores aren't reordered with other stores, loads aren't reordered with other loads. So a relaxed flag publishing data usually "works" on x86 — the hardware happens to give you release/acquire for free (only the store-buffer case above differs). AArch64 and POWER are weakly ordered: without acquire/release the reader really can see the flag before the data. Code that "works on my laptop" ships a bug to every ARM server and phone.

Two more reasons to get it right anyway: the compiler reorders relaxed operations on every architecture, and TSan (which you have here) reports the race regardless of hardware.

6. Waiting without spinning: wait / notify

C++20 atomics can block:

C++
#include <atomic>
#include <print>
#include <thread>

int main() {
    std::atomic<int> state{0};
    std::jthread waiter([&] {
        state.wait(0, std::memory_order_acquire);   // sleeps while state == 0
        std::println("woke with state {}", state.load(std::memory_order_acquire));
    });
    state.store(1, std::memory_order_release);
    state.notify_one();                             // after the store
}
atomic wait / notify: sleep until the value changes
The waiter blocks in wait(0) without spinning. The release store publishes 1, notify_one wakes the waiter, and its acquire load reads 1.
atomic wait / notify: sleep until the value changes
The waiter blocks in wait(0) without spinning. The release store publishes 1, notify_one wakes the waiter, and its acquire load reads 1.

wait(old) returns when the value is no longer old — possibly spuriously, so loop on your condition: while (!ready.load(acquire)) ready.wait(false, acquire);.

7. Cache lines and false sharing

Atomics are coherent per cache line (64 bytes on x86-64 and most ARM cores). Two atomics written by different threads that share a line make the line bounce between cores on every write — false sharing — even though the threads never touch the same variable.

C++
#include <atomic>
#include <new>

struct Shared {                       // two counters, one cache line: they fight
    std::atomic<long> a, b;
};

struct Padded {                       // one line each
    alignas(std::hardware_destructive_interference_size) std::atomic<long> a;
    alignas(std::hardware_destructive_interference_size) std::atomic<long> b;
};

static_assert(sizeof(Padded) >= 2 * std::hardware_destructive_interference_size);

int main() {}
Shared vs Padded: one cache line for both, or one line each
Each cell is 8 bytes and each row one 64-byte cache line. In Shared, a and b share a line, so the two cores keep stealing it from each other. Padded costs 128 bytes, but each core owns its line.
Shared vs Padded: one cache line for both, or one line each
Each cell is 8 bytes and each row one 64-byte cache line. In Shared, a and b share a line, so the two cores keep stealing it from each other. Padded costs 128 bytes, but each core owns its line.

Watch the coherence protocol do it. Two cores with private toy caches (16-byte lines) each run fetch_add — a read and a write — on their own counter. The badge in each slot is the line's MESI state: Modified, Exclusive, Shared, Invalid.

Shared: one line, two writers — it ping-pongs
Toy caches: 2 sets × 2 ways × 16-byte lines per core. Each write hits but has to upgrade the line S→M, invalidating the other core's copy (it goes I); so the other core's next read is a coherence miss that drags the line back. 6 increments each: 13 of 24 accesses miss and the line is invalidated 12 times — for two variables that are never shared.
Shared: one line, two writers — it ping-pongs
Toy caches: 2 sets × 2 ways × 16-byte lines per core. Each write hits but has to upgrade the line S→M, invalidating the other core's copy (it goes I); so the other core's next read is a coherence miss that drags the line back. 6 increments each: 13 of 24 accesses miss and the line is invalidated 12 times — for two variables that are never shared.
Padded: one line each — no traffic after the first touch
Same toy caches, but a and b are padded to separate lines. Each core misses once, takes its line Exclusive, upgrades it to Modified silently on the first write, and then hits every time: 2 misses, 0 invalidations.
Padded: one line each — no traffic after the first touch
Same toy caches, but a and b are padded to separate lines. Each core misses once, takes its line Exclusive, upgrades it to Modified silently on the first write, and then hits every time: 2 misses, 0 invalidations.

On a typical desktop, two threads incrementing Shared::a and Shared::b run several times slower than with Padded. Paste Padded into the Playground's Memory tab to see the gap. GCC warns about using hardware_destructive_interference_size in headers (its value can change with -mtune, which would change struct layouts across translation units), so library code often uses a named constant like cache_line = 64.

8. Spin locks done right — and why you rarely want one

A spin lock is an atomic flag. The good version is test-and-test-and-set: try to take it with an acquire exchange; while it's taken, spin on a plain relaxed load (which keeps the cache line shared instead of bouncing it with writes); release with a release store.

C++
#include <atomic>
#include <mutex>
#include <print>
#include <thread>
#include <vector>

class SpinLock {
public:
    void lock() noexcept {
        while (locked_.exchange(true, std::memory_order_acquire))
            while (locked_.load(std::memory_order_relaxed)) { }
    }
    void unlock() noexcept { locked_.store(false, std::memory_order_release); }

private:
    std::atomic<bool> locked_{false};
};

int main() {
    SpinLock lock;
    long total = 0;   // plain: protected by the lock
    {
        std::vector<std::jthread> ts;
        for (int t = 0; t < 4; ++t)
            ts.emplace_back([&] {
                for (int i = 0; i < 1000; ++i) {
                    std::lock_guard g(lock);   // SpinLock is Lockable
                    ++total;
                }
            });
    }
    std::println("{}", total);
}

Why rarely: if the lock holder is preempted, every waiter burns a full time slice doing nothing. std::mutex already spins briefly before sleeping in the kernel. Spin locks pay off only for tiny critical sections on threads that won't be preempted (kernels, real-time code, per-core data).

Faded example: a single-producer/single-consumer ring

The producer owns tail_, the consumer owns head_; the slot contents ride along with each hand-off. Fill in the memory orders and the order of operations in try_pop (what must happen before the consumer hands the slot back?):

C++
bool try_push(const T& v) {
    auto tail = tail_.load(std::memory_order_relaxed);         // our own index
    if (tail - head_.load(std::memory_order_/* ??? */) == Capacity) return false;
    slots_[tail & (Capacity - 1)] = v;
    tail_.store(tail + 1, std::memory_order_/* ??? */);
    return true;
}
std::optional<T> try_pop() {
    auto head = head_.load(std::memory_order_relaxed);
    if (head == tail_.load(std::memory_order_/* ??? */)) return std::nullopt;
    /* read the slot and publish the new head — in which order? */
}
SPSC ring: each index has exactly one writer
start / 3
Capacity 8 with four items in [head, tail). Only the consumer writes head_; only the producer writes tail_. The producer writes slots at tail and publishes them by storing tail_ (release); the consumer reads slots at head and hands them back by storing head_ (release). Each side acquire-loads the other's index.
SPSC ring: each index has exactly one writer
Capacity 8 with four items in [head, tail). Only the consumer writes head_; only the producer writes tail_. The producer writes slots at tail and publishes them by storing tail_ (release); the consumer reads slots at head and hands them back by storing head_ (release). Each side acquire-loads the other's index.
Answer

Both foreign-index loads are acquire, both index stores are release, and try_pop moves the element out of the slot before head_.store(head + 1, release) — the moment head_ moves, the producer may overwrite that slot.

Summary

  • A data race is UB. Atomics never race; memory orders decide what other data becomes visible.
  • relaxed: counters and anything nothing else depends on.
  • release store → acquire load of the same atomic reading that store: everything before the release is visible after the acquire.
  • seq_cst (default): one total order; needed for store-then-load-the-other patterns.
  • Compare-exchange lives in a loop; weak may fail spuriously.
  • x86 hides ordering bugs; ARM and the compiler don't; TSan finds them.
  • Keep independently written atomics on separate cache lines.
  • Spin locks: test-and-test-and-set, acquire/release — and usually just use a mutex.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

The definitive explanation of sequential consistency, acquire/release and why "just use seq_cst" is the default; watch both parts.

Part 2: how the orders map to x86, ARM and POWER instructions, and when relaxed is actually safe.

What atomic operations cost on real hardware, compare-exchange loops, and memory orders demonstrated with benchmarks.

Advanced: the specific patterns (counters, flags, publication helpers) where relaxed is correct — and why it isn't for general use.