The question this answers
What does C++ actually promise about concurrent memory access, and what happens when I break the promise?
A worker thread computing a result and setting a done flag; a control thread polling that flag and then reading the result.
The done flag and the result buffer, written by the worker and read by the controller. In C++ the type of those variables is not a stylistic choice — it determines whether the program has defined behaviour at all.
When the controller observes done == true, every write the worker made to result before setting the flag is visible to it — a *visibility* invariant, which no amount of correct-looking ordering in the source guarantees on its own.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
The toolbox, and what each piece is for
C++ gives you execution (std::thread, std::jthread since C++20, std::async), mutual exclusion (std::mutex, std::lock_guard, std::unique_lock, std::scoped_lock), condition variables, and atomics (std::atomic<T>) with explicit memory orderings. Unlike most languages on this platform, it also gives you the *rules*: a specification of when one thread is guaranteed to see another thread's writes.
Two details in the code below are frequently-hit traps rather than style points. First, a std::thread that is neither joined nor detached calls std::terminate in its destructor — which is why std::jthread exists and why it should be the default in new code. Second, the future returned by std::async has a destructor that blocks until the task completes; writing std::async(...) as a discarded statement therefore runs it synchronously, which looks exactly like a concurrency bug and is not one.
Note also that std::atomic<T> is not automatically lock-free. For a type too large for a hardware atomic instruction the implementation uses a lock internally, and is_lock_free() is how you find out. Lock-free is a progress guarantee, not a performance one, and [[lock-free-concepts]] and [[wait-free-vs-lock-free]] are where that gets treated properly.
1#include <thread>2#include <mutex>3#include <atomic>4#include <future>5 6std::mutex m;7std::vector<int> results; // guarded by m8std::atomic<int> completed{0}; // its own synchronisation9 10void worker(int id) {11 int value = compute(id); // no shared state touched here12 {13 std::lock_guard<std::mutex> g(m); // acquire; released at scope exit14 results.push_back(value); // the ONLY line that needs the lock15 } // release -- do not hold it longer16 completed.fetch_add(1, std::memory_order_relaxed); // counter only, no17} // ordering claimed on other data18 19int main() {20 std::vector<std::jthread> ts; // C++20: joins in its destructor.21 for (int i = 0; i < 4; ++i) // A plain std::thread that is22 ts.emplace_back(worker, i); // neither joined nor detached23 // calls std::terminate().24 25 auto fut = std::async(std::launch::async, [] { return summarise(); });26 // TRAP: writing std::async(std::launch::async, f); as a discarded27 // expression destroys the future immediately, and ~future BLOCKS until28 // the task finishes -- so it runs synchronously and looks like a bug.29 auto summary = fut.get();30} // ts destructors join all four workers here31 32// std::atomic<T> is not automatically lock-free:33static_assert(std::atomic<int>::is_always_lock_free);34// std::atomic<BigStruct>::is_lock_free() may well be false -- the35// implementation takes an internal lock, and "atomic" says nothing36// about speed. It is an indivisibility guarantee, not a fast path.Where C++ differs from everything else here
The comparison that matters is not syntax; it is what the language says when you get it wrong. Increment a shared counter from two threads without synchronisation and four runtimes give four different answers — and only one of them says your entire program is meaningless.
C++ defines a data race — two conflicting accesses to the same memory location from different threads, at least one a write, not ordered by happens-before — as undefined behaviour. Not an unspecified value, not a torn read: undefined behaviour for the whole program. The compiler is permitted to assume data races do not occur, and it optimises accordingly. A classic consequence is a polling loop on a non-atomic bool, which the compiler may legitimately hoist out of the loop into a register, turning while (!done) {} into an infinite loop that no amount of memory-barrier folklore fixes.
This is why "it works on x86" is not evidence in C++. x86 has a strong hardware memory model that hides many ordering mistakes; the compiler is the other half of the problem, and it applies the language's rules regardless of what the hardware would have done.
1int counter = 0; // NOT std::atomic2// thread A and thread B both:3counter++; // UNDEFINED BEHAVIOUR4 5// Fix:6std::atomic<int> counter{0};7counter.fetch_add(1, std::memory_order_relaxed);8// relaxed is enough for a counter: indivisible, but it publishes9// nothing else. If other threads must see data written before the10// increment, you need release/acquire. See [[memory-barriers]].A data race is undefined behaviour for the entire program, not a wrong number. The compiler may assume it cannot happen and optimise on that basis — hoisting loads out of loops, reordering, or eliminating the read entirely.
1counter = 02# two threads both:3counter += 1 # well-defined, and still wrong:4 # LOAD_GLOBAL / BINARY_OP / STORE_GLOBAL,5 # and the GIL can be released between them.6# Fix:7with lock:8 counter += 1CPython 3.12: defined semantics, interleaving at bytecode boundaries, so increments are simply lost. No undefined behaviour — the program stays meaningful and the answer is wrong.
1let counter = 02// two handlers on one agent:3counter++ // cannot race: one task at a time4 5// But across agents, with a SharedArrayBuffer:6const view = new Int32Array(sab)7view[0]++ // DATA RACE -- unsynchronised8Atomics.add(view, 0, 1) // the fixOrdinary variables cannot race, because only one task in an agent executes at a time. A SharedArrayBuffer is the exception: real shared memory, real data races, and Atomics as the only correct tool.
1// Same runtime, same rules. Types describe values,2// not thread-safety:3interface Counter { n: number }4const c: Counter = { n: 0 }5// A "readonly" or "const" annotation is erased at runtime and6// guarantees nothing about concurrent access from another agent7// through a SharedArrayBuffer.Included as the counterpoint to C++: a type system that describes shapes rather than ownership or sharing gives no concurrency guarantees at all, while C++'s type system makes std::atomic<T> the thing that changes the semantics.
- C++ is the only one of these where an unsynchronised conflicting access is undefined behaviour for the whole program rather than a wrong value.
- CPython gives defined-but-wrong results: increments are lost because
+=spans several bytecodes and the GIL can be released between them. - JavaScript cannot race on ordinary objects because one task runs at a time per agent; SharedArrayBuffer is the sole exception and needs Atomics.
- C++ makes ordering explicit through memory_order parameters; Python, JavaScript and Java give you one sequentially-consistent default and no dial.
- In C++ the compiler is as much a source of reordering as the CPU is — which is why "it works on x86" proves nothing about correctness.
The flag that never becomes true
The canonical C++ concurrency bug is not a lost update; it is a loop that never exits. A worker computes a result, writes it, and sets done = true. A controller spins on while (!done) {} and then reads the result. It works in a debug build, and hangs at -O2.
Two independent things went wrong and both are consequences of the data race. First, the compiler sees a loop whose condition reads a non-atomic variable that the loop body does not modify, is entitled to assume no other thread writes it concurrently (because that would be a data race, which it may assume does not occur), and hoists the load into a register — producing if (!done) for(;;);. Second, even if the load were re-executed, nothing orders the write to result before the write to done, so a controller that did observe the flag could still read a result that has not been published.
Making done a std::atomic<bool> fixes both at once, and it is worth being precise about why. Default atomic operations are sequentially consistent, so the store to done is a *release* and the load is an *acquire*: everything the worker wrote before the store is guaranteed visible to any thread that observes the store. That is a happens-before edge, and it is the actual product — the indivisibility of the flag itself was never the interesting part. See [[happens-before]], [[safe-publication]] and [[memory-model]].
1bool done = false; // plain bool: not atomic2std::string result; // published via the flag3 4void worker() {5 result = compute(); // (1) write the data6 done = true; // (2) publish it -- nothing orders (1) before (2)7}8 9void controller() {10 while (!done) {} // data race: concurrent read + write, unordered11 use(result); // may read a result that was never published12}13 14// What the optimiser is allowed to do with the loop:15// 'done' is not atomic and not volatile; the loop body does not modify it;16// a concurrent write would be a data race, which the compiler may assume17// cannot occur. So the load is hoisted:18// if (!done) { for (;;) {} }19// The program hangs forever. No barrier, no sleep and no volatile fixes it,20// because the defect is the data race, not the missing fence.1std::atomic<bool> done{false};2std::string result; // still a plain string -- that is fine3 4void worker() {5 result = compute(); // (1)6 done.store(true, std::memory_order_release); // (2) RELEASE:7} // (1) is ordered before (2)8 9void controller() {10 while (!done.load(std::memory_order_acquire)) { // ACQUIRE: pairs with11 std::this_thread::yield(); // the release above12 }13 use(result); // guaranteed to see the write from (1)14}15 16// The atomic does two jobs and only one of them is "indivisible":17// 1. the load is re-executed every iteration -- no hoisting;18// 2. release/acquire creates a happens-before edge, so every write19// the worker made BEFORE the store is visible to the observer.20// Spinning still burns a core: a condition_variable is usually better.21// See [[busy-waiting]] and [[condition-variables]].The plain-bool version has a data race and is therefore undefined behaviour, which lets the compiler hoist the load and produce an infinite loop — and separately leaves result unpublished. std::atomic<bool> removes the race and, through the release/acquire pairing, establishes the happens-before edge that makes the *non-atomic* result safe to read. Publishing data through an atomic flag is the mechanism; the flag's own indivisibility is the least interesting part of it.
Key points
- C++11 introduced a formal memory model: a data race is undefined behaviour for the whole program, not a stale or torn value.
- The compiler may assume data races do not occur and optimise on that assumption — hoisting loads, reordering stores, deleting reads.
- "It works on x86" proves nothing. The compiler reorders as freely as the hardware does, and x86's strong model hides only the hardware half.
- The product of
std::atomicis usually the happens-before edge, not the indivisibility: release/acquire is what publishes the non-atomic data written beforehand. std::atomic<T>is not automatically lock-free; checkis_lock_free(), and remember lock-free is a progress guarantee rather than a speed promise.- A
std::threadneither joined nor detached callsstd::terminatein its destructor. Preferstd::jthread(C++20), which joins. - A discarded
std::asyncfuture blocks in its destructor, so the task runs synchronously — a trap that looks exactly like a threading bug. volatileis not a threading tool in C++. It is for memory-mapped hardware and gives no atomicity and no ordering between threads.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- •
std::threadandstd::jthreadcreate OS threads that share the process address space and can execute on separate cores. - •
std::mutexprovides mutual exclusion, and its lock/unlock pair also creates a happens-before edge so writes inside the region are visible to the next holder. - •
std::atomic<T>provides indivisible operations plus, through itsmemory_orderargument, control over what else is ordered around them. - • Release on a store and acquire on a load pair up: everything sequenced before the release is visible to any thread that observes the released value.
- • Absent such an edge, the compiler and the CPU may both reorder and cache, and the standard offers no guarantee about what another thread observes.
- •
std::asyncwithlaunch::asyncruns the task on a new thread and delivers the result — or the exception — through a future whoseget()synchronises.
- • Worker writes result, stores done with release; controller loads done with acquire and sees true, then reads result — the correct schedule, with the edge doing the work.
- • Plain bool,
-O2: the controller's load is hoisted out of the loop; done is never re-read; the program spins forever regardless of what the worker does. - • Plain bool, no hoisting: the controller observes done == true before the write to result is visible, and reads an empty string. Correct-looking source, unpublished data.
- • Two threads
push_backon the same vector understd::lock_guard: serialised, and the release/acquire pairing of the mutex also makes each other's elements visible. - • Two threads
push_backwithout the lock: concurrent modification of the internal pointers, a data race, undefined behaviour — commonly a corrupted heap or a crash somewhere unrelated later. - • Both threads use
fetch_add(1, relaxed)on a counter: the count is correct because the operation is indivisible, and nothing else is ordered — data written before the increment is not published by it.
- • The standard guarantees that a program free of data races behaves as some interleaving of its threads' operations, respecting the memory orderings you specified.
- • It guarantees a mutex provides mutual exclusion *and* the visibility edge between the releasing and acquiring threads.
- • It guarantees
std::atomicoperations are indivisible, and that the ordering you request is respected by both the compiler and the hardware. - • It guarantees nothing at all about a program containing a data race. Undefined behaviour is not "an unspecified value" — the whole program is outside the standard.
- • It does not guarantee that
std::atomic<T>is lock-free, nor that atomic operations are fast, nor that lock-free code outperforms a mutex. - •
memory_order_relaxedguarantees indivisibility only. It publishes nothing, orders nothing, and using it to hand data between threads is a bug.
- • A contended mutex costs a syscall-scale wait once the futex fast path fails; an uncontended one is tens of nanoseconds.
- • Contended atomics on one cache line cost a coherence transfer per operation, which is why an atomic counter incremented by every core can be slower than a lock.
- • False sharing — unrelated variables on the same 64-byte cache line — produces contention with no lock and no shared variable in sight. See
[[false-sharing]]. - • Spinning burns a core while waiting; it wins only when the expected wait is shorter than a context switch, and loses badly when the holder is descheduled. See
[[busy-waiting]]. - • Sequentially consistent atomics can emit stronger fences than release/acquire, which costs on weakly-ordered architectures such as ARM and costs little on x86.
- • Data race: undefined behaviour, presenting as an impossible value, a hang, a crash somewhere unrelated, or a bug that appears only under optimisation.
- • Hoisted load on a non-atomic flag: an infinite loop that exists only in the optimised build.
- • Unpublished data: the flag is atomic but the ordering is relaxed, so the reader sees the flag and not the data it was supposed to announce.
- • Torn read of a type too large for a hardware atomic, when accessed without synchronisation.
- • Deadlock from inconsistent lock ordering —
std::scoped_lockwith several mutexes avoids it by using a deadlock-avoidance algorithm. See[[lock-ordering]]. - •
std::terminatefrom astd::threaddestroyed while joinable — a crash with no obvious connection to the thread that caused it. - • Accidental synchronous execution from a discarded
std::asyncfuture, whose destructor blocks.
- • Compute-bound work that must use every core with no runtime tax: C++ threads map directly to OS threads and share memory with no copying.
- • Systems where the memory ordering must be controlled deliberately — lock-free structures, real-time paths, allocators, schedulers.
- • Anywhere the ability to reason about visibility precisely is worth the obligation to do so, which is the trade the language offers.
- • Application code where the discipline is not worth the risk: one undefined-behaviour bug can cost more than the performance ever earned.
- • Very high concurrency counts, where a thread each is unaffordable and coroutines or an event-driven design fit better.
- • Teams without race detectors and sanitisers in CI, because the failure mode is silent and optimiser-dependent.
- • Lock-free code written without a measured reason. It is dramatically harder to get right and frequently slower. See
[[lock-free-concepts]].
- • ThreadSanitizer (
-fsanitize=thread) in CI. It finds data races that have never produced a symptom, which is the only reliable way to find them. - • A build at
-O2or higher for every concurrency test — debug builds hide exactly the optimisations that expose these bugs. - • Contended-acquisition counts and lock wait times per mutex, to distinguish an expensive lock from a busy one.
- •
is_lock_free()at compile time viastatic_assert, so an accidentally lock-based atomic is a build failure rather than a mystery. - • Cache-miss and coherence-traffic counters (
perf stat) when a parallel version underperforms; false sharing shows up here and nowhere else. - • Test on a weakly-ordered architecture such as ARM, where ordering mistakes that x86 conceals actually manifest.
- • You must know the memory model to write correct code — this is the only mainstream language on this platform where visibility is your explicit responsibility.
- • Every shared variable carries a decision: plain and locked, atomic with an ordering, or thread-confined. Getting it wrong is undefined behaviour rather than a wrong value.
- • Lifetimes interact with threads: an object captured by reference must outlive the thread using it, and detached threads make that nearly impossible to reason about.
- • RAII helps enormously —
lock_guard,scoped_lock,jthread— and the pre-C++20 legacy patterns they replace are still everywhere in existing code. - • The toolchain is part of correctness: sanitisers, optimised-build testing, and ideally a weakly-ordered target in CI.
- • A
std::mutexinstead of atomics. Simpler, correct by default, and frequently faster than a contended atomic. Reach for atomics with a measurement in hand. - • Do not share: thread-confine the data, or give each thread a private copy and merge at the end. See
[[copy-vs-share]]. - • Immutable data plus a shared pointer swap, which turns publication into a single atomic store of a pointer.
- • Higher-level parallel algorithms —
std::reduce,std::for_eachwith an execution policy, TBB — which have already made these decisions correctly. - • Message passing over a concurrent queue, which confines shared mutable state to one well-tested component. See
[[message-passing]].
Both threads read 0, and both wrote first
x = y = 0
Thread 1 Thread 2
x = 1; y = 1;
r1 = y; r2 = x;
Sequential reasoning: one of the two stores must land first,
so at least one load must see a 1. r1 == 0 && r2 == 0 is impossible.| # | Thread 1 | Thread 2 | State |
|---|---|---|---|
| 1 | x ← 1 | · | x=1 y=0 r1=0 r2=0 |
| 2 | r1 ← y | · | x=1 y=0 r1=0 r2=0 |
| 3 | · | y ← 1 | x=1 y=1 r1=0 r2=0 |
| 4 | · | r2 ← x | x=1 y=1 r1=0 r2=1 |
counter++ with and without atomicity
r ← counter r ← r + 1 counter ← r
fetch_add(counter, 1) # no schedule can cut inside this
Race detector lab
| task | access | location | holding |
|---|---|---|---|
| A | read | count | — |
| A | write | count | — |
| B | read | count | — |
| B | write | count | — |
| # | Task A | Task B | State |
|---|---|---|---|
| 1 | r1 = count | · | count=0 done=0 |
| 2 | · | r2 = count | count=0 done=0 |
| 3 | count = r1 + 1 | · | count=1 done=1 |
| 4 | · | count = r2 + 1 | count=1 done=2 ✕ count equals the number of increments that have completed — broken here |
Publish a value, then a flag — which edge makes it visible?
Writer Reader
data = 42; while (ready == 0) { }
ready = 1; use(data);| # | Writer | Reader | State |
|---|---|---|---|
| 1 | data ← 42 | · | data=42 ready=0 reader sees=— |
| 2 | ready ← 1 (plain store) | · | data=42 ready=1 reader sees=— |
| 3 | · | read ready → 1 | data=42 ready=1 reader sees=— |
| 4 | · | read data → 0 | data=42 ready=1 reader sees=0 ✕ the reader observed ready = 1 and data = 0 — it saw the flag that announces the write without seeing the write |
What people believe, and what is true
A data race just means I might read a stale value.
In C++ it is undefined behaviour for the whole program. The compiler may assume it cannot happen, and the observed result can be a hang, a crash, or a value no interleaving could produce.
volatile makes a variable thread-safe.
volatile is for memory-mapped I/O. It provides no atomicity and no inter-thread ordering. std::atomic is the threading tool.
It works on x86, so the ordering is fine.
x86 hides many hardware reorderings and hides none of the compiler's. The same code is undefined behaviour on x86 too; you were lucky, not correct.
std::atomic makes the counter fast.
It makes the operation indivisible. A counter incremented by eight cores on one cache line is a coherence bottleneck and can be slower than a mutex.
Lock-free is faster than locking.
Lock-free is a progress guarantee: some thread always makes progress. Under contention it often performs worse than a well-scoped mutex, and it is far harder to get right.
Go deeper
Overview
C++ gives you real threads, real shared memory and a defined memory model — plus the rule that an unsynchronised conflicting access makes the whole program undefined.
Practical
Lock what is shared, use std::atomic for flags and counters, prefer jthread and scoped_lock, and run ThreadSanitizer at -O2 in CI. Never publish data through a relaxed atomic.
Advanced
The real product of an atomic is usually the happens-before edge, not the indivisibility. Release/acquire is how non-atomic data becomes safely visible, which is why memory_order is a correctness parameter rather than a tuning knob — relaxed is correct for a statistics counter and a bug for a publication flag.