Processes, Threads & Tasks

C++: Threads, Atomics and a Memory Model With Teeth

std::thread, std::async, std::mutex, std::atomic — and, since C++11, a formally defined memory model in which a data race is undefined behaviour. Not "you get a stale value": undefined, meaning the compiler was entitled to assume it could not happen and optimised on that basis.

▶ Run the lab

The question this answers

The question

What does C++ actually promise about concurrent memory access, and what happens when I break the promise?

The work

A worker thread computing a result and setting a done flag; a control thread polling that flag and then reading the result.

What is shared

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.

The invariant — what must stay true under every interleaving

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.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

The toolbox, and what each piece is for

language-specific· C++17 with C++20 additions marked. `std::jthread` and `std::stop_token` require C++20.

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 m
8std::atomic<int> completed{0}; // its own synchronisation
9
10void worker(int id) {
11 int value = compute(id); // no shared state touched here
12 {
13 std::lock_guard<std::mutex> g(m); // acquire; released at scope exit
14 results.push_back(value); // the ONLY line that needs the lock
15 } // release -- do not hold it longer
16 completed.fetch_add(1, std::memory_order_relaxed); // counter only, no
17} // ordering claimed on other data
18
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 is
22 ts.emplace_back(worker, i); // neither joined nor detached
23 // 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 discarded
27 // expression destroys the future immediately, and ~future BLOCKS until
28 // the task finishes -- so it runs synchronously and looks like a bug.
29 auto summary = fut.get();
30} // ts destructors join all four workers here
31
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 -- the
35// implementation takes an internal lock, and "atomic" says nothing
36// about speed. It is an indivisibility guarantee, not a fast path.
The four tools, with the two destructor traps marked.

Where C++ differs from everything else here

language-specific· C++17/20; CPython 3.12; ECMAScript with SharedArrayBuffer where noted.

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.

Two threads increment a shared counter with no synchronisation. Four languages, four verdicts. — Unsynchronised concurrent read-modify-write on a shared counter
C++
1int counter = 0; // NOT std::atomic
2// thread A and thread B both:
3counter++; // UNDEFINED BEHAVIOUR
4
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 publishes
9// nothing else. If other threads must see data written before the
10// 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.

PythonCPYTHON
1counter = 0
2# 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 += 1

CPython 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.

JavaScriptBROWSER
1let counter = 0
2// two handlers on one agent:
3counter++ // cannot race: one task at a time
4
5// But across agents, with a SharedArrayBuffer:
6const view = new Int32Array(sab)
7view[0]++ // DATA RACE -- unsynchronised
8Atomics.add(view, 0, 1) // the fix

Ordinary 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.

TypeScriptNODE.JS
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 and
6// guarantees nothing about concurrent access from another agent
7// 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.

What actually differs
  • 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

language-specific· C++11 and later. Default atomic operations are sequentially consistent; the explicit release/acquire above is the minimum ordering that makes this pattern correct.

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]].

Undefined behaviour. Works in debug, hangs at -O2, and the flag is not the only problem.
1bool done = false; // plain bool: not atomic
2std::string result; // published via the flag
3
4void worker() {
5 result = compute(); // (1) write the data
6 done = true; // (2) publish it -- nothing orders (1) before (2)
7}
8
9void controller() {
10 while (!done) {} // data race: concurrent read + write, unordered
11 use(result); // may read a result that was never published
12}
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 assume
17// 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.
Defined behaviour, and the release/acquire edge is the actual product.
1std::atomic<bool> done{false};
2std::string result; // still a plain string -- that is fine
3
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 with
11 std::this_thread::yield(); // the release above
12 }
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 write
19// 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::atomic is 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; check is_lock_free(), and remember lock-free is a progress guarantee rather than a speed promise.
  • A std::thread neither joined nor detached calls std::terminate in its destructor. Prefer std::jthread (C++20), which joins.
  • A discarded std::async future blocks in its destructor, so the task runs synchronously — a trap that looks exactly like a threading bug.
  • volatile is 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.

How it works
  • std::thread and std::jthread create OS threads that share the process address space and can execute on separate cores.
  • std::mutex provides 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 its memory_order argument, 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::async with launch::async runs the task on a new thread and delivers the result — or the exception — through a future whose get() synchronises.
Interleavings that matter
  • 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_back on the same vector under std::lock_guard: serialised, and the release/acquire pairing of the mutex also makes each other's elements visible.
  • Two threads push_back without 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.
What it guarantees — and does not
  • 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::atomic operations 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_relaxed guarantees indivisibility only. It publishes nothing, orders nothing, and using it to hand data between threads is a bug.
Where contention appears
  • 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.
How it fails
  • 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_lock with several mutexes avoids it by using a deadlock-avoidance algorithm. See [[lock-ordering]].
  • std::terminate from a std::thread destroyed while joinable — a crash with no obvious connection to the thread that caused it.
  • Accidental synchronous execution from a discarded std::async future, whose destructor blocks.
When it helps
  • 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.
When it hurts
  • 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]].
How you would know
  • 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 -O2 or 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 via static_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.
Complexity it introduces
  • 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.
Simpler alternatives
  • A std::mutex instead 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_each with 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

Both threads read 0, and both wrote first
Two threads, two variables, four operations. Reason sequentially about it and one outcome is provably impossible: whichever store happened first, the other thread's load comes after it. Then let the load move up one line.
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.
0 of 6 schedules give (0,0)
r1=0, r2=0
never
r1=0, r2=1
1 schedule
r1=1, r2=0
1 schedule
r1=1, r2=1
4 schedules
Invariant · r1 == 0 && r2 == 0 cannot both hold — at least one thread must observe the other's store.
#Thread 1Thread 2State
1x ← 1·x=1 y=0 r1=0 r2=0
2r1 ← y·x=1 y=0 r1=0 r2=0
3·y ← 1x=1 y=1 r1=0 r2=0
4·r2 ← xx=1 y=1 r1=0 r2=1
As written, all 6 interleavings are enumerated above and none produces (0,0). That is what sequential consistency buys you: the program behaves as if there is one global order of operations that respects each thread's program order. Every argument you make about concurrent code by "walking through it" assumes this — and no mainstream language guarantees it for ordinary variables. The repair is not to reason harder about timing; it is to declare the ordering you need. A release store paired with an acquire load, a sequentially-consistent atomic, or an explicit fence turns the pair of accesses into a happens-before edge the compiler and the CPU must both respect. Ordering is something you *request*, and the cost of requesting it is the reason it is not the default.
CPU-SPECIFICWhether this reordering is observable depends on the processor, the compiler and the language memory model. x86 is strong and still permits exactly this case; ARM and POWER permit far more. The hardware mechanism — store buffers, invalidation queues, speculative loads — belongs to Computer Architecture; this lab only shows what the *program* can observe.

counter++ with and without atomicity

counter++ with and without atomicity
The same program on both sides: N threads, one shared counter, one increment each. On the left counter++ is read, add, write. On the right it is a single indivisible instruction. Every schedule of both is enumerated.
20 schedules enumerated on the left, 2 on the right
counter++ — read, add, write
r ← counter
r ← r + 1
counter ← r
schedules
20
lose an update
18
end at 2
2
worst case
1
final counter = 118 · 18 of 20 schedules
final counter = 22 · 2 of 20 schedules
atomic fetch_add — one indivisible step
fetch_add(counter, 1)   # no schedule can cut inside this
schedules
2
lose an update
0
end at 2
2
worst case
2
final counter = 22 · 2 of 2 schedules — the order still varies, the outcome does not
The threads still interleave. Atomicity does not remove the schedules; it removes the points at which a schedule can cut.
The non-atomic version, run 200 times under a random scheduler
runs that produced the right answer59 · 29.5% — a green test suite
runs that lost an update141 · 70.5%
With 2 threads there are 20 schedules of read/add/write and 18 of them — 90.0% — end with a counter smaller than 2. The worst is 1: every thread read 0, every thread computed 1, and the last write erased the rest. And yet 59 of the 200 sampled runs above produced exactly 2. That is why the non-atomic version passes tests. A test does not explore the schedule space, it samples it, and the sampling is biased by whatever the machine happened to be doing. 29.5% green is not 29.5% correct — the invariant is "after k completed increments, counter === k", and it is false in 18 legal schedules whether or not today's run found one. The right-hand column does not test better, it removes the schedules: an atomic read-modify-write has no interior for the scheduler to cut into. That buys correctness for one variable only — atomics compose badly, and two atomic operations in a row are not one atomic operation.
SIMPLIFIEDSchedule counts are exact for this model of the program. counter++ is modelled as three indivisible steps; a real compiler may split it further, and a real CPU may fuse it into one atomic instruction — which is exactly the right-hand column.

Race detector lab

Race detector: what it catches and what it cannot
The left panel is a detector — it looks at accesses and locks. The right panel enumerates every schedule and checks the invariant. They do not always agree, and that disagreement is the lesson.
Scenario
Two tasks run count = count + 1 with nothing around it.
Detector · accesses observed
taskaccesslocationholding
Areadcount
Awritecount
Breadcount
Bwritecount
DATA RACE A.readB.write — read/write on count from different tasks with no common lock
DATA RACE A.writeB.read — write/read on count from different tasks with no common lock
DATA RACE A.writeB.write — write/write on count from different tasks with no common lock
Enumerator · 6 schedules explored
Invariant · count equals the number of increments that have completed
#Task ATask BState
1r1 = count·count=0 done=0
2·r2 = countcount=0 done=0
3count = r1 + 1·count=1 done=1
4·count = r2 + 1count=1 done=2
✕ count equals the number of increments that have completed — broken here
Shown: the first schedule that breaks the invariant. Every step is a legal execution — no compiler trick, no exotic hardware, just an ordering the scheduler is allowed to pick.
3 unsynchronized conflicting pairs on count, at least one of them a write. That is a data race by definition, and in C++ it is undefined behaviour rather than a wrong number. 4 of 6 schedules also break the invariant, so this one is a race condition too.
Both a data race and a race condition, which is why this example teaches so badly on its own: it lets people believe the two words mean the same thing.
Real detectors (ThreadSanitizer, Helgrind, Go’s -race) work on happens-before edges observed at runtime, so they only report races on code paths that actually executed, and they slow the program enough to change its timing. This model shows the reasoning, not their output.
3 data races4/6 schedules break the invariantSIMULATED

Publish a value, then a flag — which edge makes it visible?

Publish a value, then a flag
The writer fills in the data and sets a ready flag. The reader waits for the flag and reads the data. It looks airtight, and without a synchronization edge between the two threads it is not — no matter how long the reader waits.
Writer                          Reader
    data  = 42;                     while (ready == 0) { }
    ready = 1;                      use(data);
reader starts
edge
none
reader delay
immediately
values data may show
42 or 0
guarantee
none
Invariant · if the reader observes ready == 1, it observes data == 42.
#WriterReaderState
1data ← 42·data=42 ready=0 reader sees=—
2ready ← 1 (plain store)·data=42 ready=1 reader sees=—
3·read ready → 1data=42 ready=1 reader sees=—
4·read data → 0data=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
Without an edge the reader can observe ready = 1 and data = 0. Both writes happened, in that order, in the writer's source. The reader simply has no relation to them: the two plain stores may be published out of order, and the reader's two plain loads may be satisfied out of order or from a stale cached value. Happens-before is a partial order the language defines over operations, built from program order plus specific synchronizing pairs — a lock release and the next acquire of the same lock, a release store and the acquire load that reads it, a thread start, a thread join, a channel send and its receive. Two operations with no path between them in that order are concurrent, and a data race on them is undefined behaviour rather than a stale value: the compiler is entitled to assume it never happens. So the question to ask of any cross-thread visibility argument is never "could the other thread really be that fast?" — it is which edge makes this visible?
SIMPLIFIEDModelled at the level of a language memory model: an edge exists or it does not. Which spellings create one is language-specific — release/acquire in C++ and Rust, a lock or a volatile write in Java, a channel send or a lock in Go.

What people believe, and what is true

Claim

A data race just means I might read a stale value.

Reality

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.

Claim

volatile makes a variable thread-safe.

Reality

volatile is for memory-mapped I/O. It provides no atomicity and no inter-thread ordering. std::atomic is the threading tool.

Claim

It works on x86, so the ordering is fine.

Reality

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.

Claim

std::atomic makes the counter fast.

Reality

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.

Claim

Lock-free is faster than locking.

Reality

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.

Apply it