all_lessons/C++/17 · Threads & raceslesson 18 / 20

Concurrency I: threads, races, and the memory you share

Lesson 16 finished the language's sequential story: types, const, and constexpr let you push errors to compile time and make illegal states unrepresentable — all under the comforting assumption that statements run one after another. This lesson removes that assumption. The moment two threads — independent streams of execution running in the same address space — touch the same memory, the cost model gains a new axis. The one new idea here is the data race: a precisely-defined error that is undefined behavior, the bug that ownership and lifetime discipline alone cannot prevent, and the reason shared mutable state is the enemy of correctness.

The thesis, here
Concurrency is the place where C++'s "no runtime between you and the machine" stops being a convenience and becomes a contract you must uphold. There is no global interpreter lock standing guard, no runtime serializing your accesses. A thread is mapped to a real OS thread on a real core; two cores can write the same address in the same nanosecond. So the cost-model questions get a sibling: is this memory reached by more than one thread, and if so, who synchronizes the access? Get the answer wrong and you don't get a slow program — you get undefined behavior.
Linear position
Prerequisite: Lesson 05 (RAII — a destructor runs deterministically at scope exit, even on an early return or exception) and Lesson 08 (the cache, the ~64-byte cache line, and why two cores fighting over a line is expensive). We lean on both directly: RAII makes locks leak-proof, and the cache line explains false sharing.
New capability: launch and join threads with std::thread, state the data-race definition exactly and recognize it in code, protect shared state with a std::mutex guarded by an RAII lock, avoid deadlock by ordering locks, and diagnose false sharing — a performance bug with no logical sharing at all.
The plan
Six moves. (1) Threads: what they are and the shared-address-space model. (2) The data race, defined to the letter, and why it is UB. (3) Why shared mutable state is the enemy — the same lesson functional programming teaches from the other side. (4) std::mutex and RAII locks (lock_guard / unique_lock) — RAII from Lesson 05 returning as the fix. (5) Deadlock, and lock ordering as the cure. (6) False sharing — Lesson 08's cache line returning as a concurrency bug — with a before/after.

1 · A thread is a second program counter in the same memory

A thread is an independent sequence of execution: its own program counter (which instruction is next) and its own stack (Lesson 02 — the region holding local variables and return addresses). Crucially, all threads in one process share the same address space — the same heap, the same global/static variables, the same code. That is the whole point and the whole danger: a pointer in one thread refers to the same byte as the identical pointer in another. The OS may schedule those threads on different physical cores, so they truly run at the same instant.

In C++ you create one with std::thread from <thread>. You hand it a callable; it begins running immediately on a new thread. Before that std::thread object is destroyed you must either join it (block until it finishes) or detach it (let it run independently). Forgetting both is not a leak you can ignore — the destructor calls std::terminate() and kills the program. This is RAII (Lesson 05) enforcing a rule: a thread handle that owns a running thread must resolve that ownership before it dies.

#include <thread>
#include <iostream>

void greet(int id) { std::cout << "thread " << id << '\n'; }

int main() {
    std::thread t1(greet, 1);     // starts running NOW, on its own stack
    std::thread t2(greet, 2);     // a second, concurrent stream
    t1.join();                    // block until t1 finishes
    t2.join();                    // block until t2 finishes
    // if we forgot join()/detach(), ~thread() calls std::terminate()
}

Note what is not shared and what is. id inside greet is a parameter on each thread's own stack — private. But std::cout is a global object both threads write to; the interleaving of their output is unsynchronized, which is the first hint of the problem this lesson is about.

2 · The data race, defined precisely

"Race condition" gets used loosely. C++ defines one specific, formal error — the data race — and the definition is worth memorizing word for word, because every word is load-bearing:

Data race (the exact definition)
A data race occurs when two threads access the same memory location, at least one of the accesses is a write, and there is no synchronization ordering the two accesses. A program containing a data race has undefined behavior — Lesson 19's territory: not a wrong answer, but no defined answer at all, with the compiler free to assume it never happens.

Unpack the three conditions, because correctness lives in the gaps between them:

same location, both reading
Two threads reading the same variable, neither writing — not a race. Immutable shared data is always safe. (This is the whole basis of copy-on-write and of FP's appeal.)
at least one write, unsynchronized
One reads while another writes, or both write — this is the race. The standard gives no meaning to a torn or reordered access here.
"with synchronization" is the escape
A mutex, an atomic (Lesson 18), or a join establishes a happens-before ordering. With it, even concurrent writes are well-defined. The race is defined as the absence of this.

Why undefined behavior, and not merely "the last writer wins"? Because the hardware and compiler do not guarantee that x++ is one indivisible step. On most machines it is at least three: load x into a register, add one, store it back. Two threads can both load the same old value, both add one, both store — and one increment vanishes. Worse, the compiler (Lesson 01 — it optimizes hard) is permitted to assume no data races exist, so it may keep x in a register across a loop, reorder the store, or fuse reads — optimizations that are perfectly valid for single-threaded code and silently corrupt racing code. The data race is UB precisely so the compiler can keep doing those optimizations (Lesson 19 closes this loop).

Worked example — the lost-update race
Two threads each increment a shared counter 100,000 times. With no synchronization:
#include <thread>
#include <iostream>

long counter = 0;                       // shared, mutable, global

void add() {
    for (int i = 0; i < 100000; ++i)
        ++counter;                      // load, +1, store — NOT atomic
}

int main() {
    std::thread a(add), b(add);
    a.join(); b.join();
    std::cout << counter << '\n';      // "should" be 200000...
}
This program has a data race, so it is undefined behavior — but in practice it prints something like 137492, a different wrong number each run. The trace shows why: thread A loads counter=41, thread B loads 41, A computes 42 and stores, B computes 42 and stores — two ++s, one net increment. Tens of thousands of updates are lost to this interleaving. The fix comes in §4.

3 · Shared mutable state is the enemy

Read the data-race definition again and notice which combinations are safe. Reads-only of the same data: safe. Writes to data only one thread can reach: safe. The race needs the intersection of three things — sharing, mutation, and the absence of synchronization. Remove any one and the race is gone. That gives three escape routes, and the cheapest two remove a property rather than add machinery:

don't share
Give each thread its own copy or its own slice of the data. No shared location, no race. (This is why parallel algorithms partition their input.)
don't mutate
If shared data is never written after threads start, all accesses are reads — no race, no lock, no cost. const (Lesson 16) is your evidence.
if you must do both, synchronize
When data is genuinely shared and mutated, you pay for ordering — a mutex (§4) or an atomic (Lesson 18). This is the only path that costs you.

This is the exact lesson functional programming teaches from the other side. FP makes data immutable by default and discourages shared state, and as a happy consequence FP code is often trivially parallel — there is nothing to race over. C++ arrives at the same wisdom from the cost-model direction: synchronization is a real, measurable cost (a mutex lock is tens of nanoseconds; contention is far worse), so the cheapest correct concurrent program is the one with the least shared mutable state. Immutable or unshared data has no races. Reach for a lock only when you have argued you cannot avoid sharing-plus-mutation.

4 · std::mutex and RAII locks

When you genuinely must share and mutate, you need mutual exclusion: a guarantee that at most one thread is inside a critical section at a time. std::mutex (from <mutex>) provides it. lock() blocks until the calling thread holds the mutex; unlock() releases it. Holding the mutex while touching the shared data establishes the happens-before ordering the data-race definition demanded, so the access is no longer a race.

But calling unlock() by hand is exactly the unwinnable-by-discipline problem Lesson 04 showed for delete: an early return, a break, or a thrown exception skips the unlock() and the mutex stays locked forever — every other thread blocks on it and the program hangs. The answer is the same as for memory: RAII (Lesson 05). An RAII lock acquires the mutex in its constructor and releases it in its destructor, so the unlock happens automatically at scope exit on every path, including exceptions.

RAII lockWhat it isUse when
std::lock_guard<std::mutex>Smallest possible wrapper: locks on construction, unlocks on destruction. No other operations.The common case — one critical section, lock the whole scope.
std::unique_lock<std::mutex>Same RAII guarantee, but movable and can defer, unlock early, or re-lock; required by condition variables.You need to unlock before scope end, transfer the lock, or wait on a condition.
std::scoped_lock<...>Like lock_guard but locks several mutexes at once, deadlock-free (see §5).You must hold two or more mutexes together.
Worked example — the race fixed with a lock_guard
The lost-update counter from §2, now correct:
#include <thread>
#include <mutex>

long counter = 0;
std::mutex m;                            // guards counter

void add() {
    for (int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lk(m);  // acquire here...
        ++counter;                         // critical section
    }                                      // ...release at scope exit, always
}
// now both threads see well-ordered accesses; result is exactly 200000.
The lock_guard lk locks m when constructed and unlocks it when it goes out of scope at the closing brace — even if ++counter threw. One thread is inside the critical section at a time, so the three-step load/add/store can no longer interleave. We have traded the bug for a cost: each iteration now pays the mutex's lock/unlock and serializes on contention. That is the §3 lesson made concrete — synchronization is the path that costs you, which is why you minimize the time spent holding the lock (note the guard is inside the loop body, not wrapped around it, only because here the body is the shared work).

5 · Deadlock and lock ordering

A mutex fixes races but introduces a new failure mode. A deadlock is a standstill in which two or more threads each hold a lock the other needs, so none can proceed — the program hangs forever, using no CPU. The classic recipe is two mutexes acquired in opposite orders:

std::mutex m1, m2;

void thread_A() {
    std::lock_guard<std::mutex> a(m1);   // holds m1, wants m2
    std::lock_guard<std::mutex> b(m2);
}
void thread_B() {
    std::lock_guard<std::mutex> a(m2);   // holds m2, wants m1
    std::lock_guard<std::mutex> b(m1);
}
// interleave: A locks m1, B locks m2, A blocks on m2, B blocks on m1 — forever.

The fix is a discipline, not a feature: lock ordering — establish a single global order in which mutexes are always acquired (e.g. always m1 before m2, by address or by a documented hierarchy) and never violate it. If every thread that needs both takes them in the same order, the circular wait that defines deadlock cannot form. When you must take several locks at once, the language gives you a tool that enforces a deadlock-free order for you: std::scoped_lock lk(m1, m2); locks both atomically using an internal algorithm that never deadlocks. Prefer it to nesting two lock_guards by hand.

6 · False sharing — the cache line returns

The last failure mode is not a correctness bug at all — it is a performance bug with no logical sharing, and it is Lesson 08 coming back to bite. Recall: memory moves between cores in cache lines of about 64 bytes, and a line written by one core must be invalidated in every other core's cache before they can use it (the cache-coherence protocol). False sharing happens when two threads write to different variables that happen to sit in the same cache line. Logically they share nothing — no race, no lock needed — but the hardware ping-pongs the whole line between cores on every write, and throughput collapses.

Worked example — false sharing before/after
Two threads, each summing into its own counter. The counters are adjacent in one array, so they land in one 64-byte line:
struct BadCounters { long a; long b; };   // a and b in the SAME cache line
BadCounters c;
// thread 1: for (...) c.a += work();   thread 2: for (...) c.b += work();
//   no data race (different locations) — but the line ping-pongs between
//   cores on every write. Measured: ~5-10x SLOWER than expected.
The fix is to push the two counters onto different cache lines with padding or alignment:
struct alignas(64) Padded { long value; };   // each occupies its own line
Padded c[2];
// thread 1 writes c[0].value, thread 2 writes c[1].value — different lines.
// No coherence traffic between them; throughput returns to near-linear.
// (C++17: std::hardware_destructive_interference_size names the 64 for you.)
Same logic, same correctness, ~one line of padding — and a large measured speedup. The diagnosis only makes sense if you carry Lesson 08's mental model of the cache line into concurrent code.

Common mistakes / failure modes

Forgetting join/detach
A std::thread destroyed while still owning a running thread calls std::terminate(). Always join() or detach() before it dies (RAII is enforcing this).
Calling unlock() by hand
An early return or exception skips it and the mutex stays locked forever. Use lock_guard/unique_lock; never call lock/unlock directly.
"It printed the right answer once"
A data race is UB regardless of the observed output. A racy program that "works" is one interleaving away from corruption — only the absence of a race is correct.
Locks taken in different orders
Two mutexes, opposite orders, equals deadlock. Pick one global order or use std::scoped_lock for multiple at once.
A dangling reference into a thread
If a detached thread captures a reference to a local that goes out of scope, it reads freed memory (Lesson 04's use-after-free, now racing). Capture by value or guarantee the lifetime.
Assuming a lock fixes false sharing
False sharing has no logical sharing to protect — a lock would only make it slower. The fix is layout (padding/alignment), not synchronization.

Checkpoint exercise

Try it
Compile the §2 unsynchronized counter and run it ten times — record the ten different wrong totals, then run it under a thread sanitizer: g++ -fsanitize=thread -g race.cpp && ./a.out. TSan will report the exact data race (the two unsynchronized accesses to counter). Now add the lock_guard from §4, rebuild under TSan, and confirm the report is gone and the total is exactly 200000. For the layout half: build the §6 BadCounters and Padded versions, time both with two threads, and predict the ratio before you measure — you should see several-fold improvement from one line of alignas(64), with no change in the answer. Then deliberately create a two-mutex deadlock and watch the program hang at 0% CPU.

Where this points next

We protected shared state with a mutex — a heavyweight tool that blocks threads and costs tens of nanoseconds even uncontended. But the §2 counter only needed one indivisible ++; surely there is something lighter than locking an entire critical section to increment an integer. There is, and it forces us to confront the question we have so far waved past with the phrase "with synchronization": what does it actually mean for two accesses to be ordered, and what is the compiler and CPU allowed to reorder when they aren't? Lesson 18 introduces std::atomic, explains why a plain ++ isn't atomic, and opens the C++ memory model — the rules of reordering and the memory orderings (relaxed, acquire-release, seq-cst) — which is the formal machinery underneath the word "synchronization" we have been leaning on all lesson.

Takeaway
A std::thread is a second program counter and stack running concurrently in the same address space, so threads share the heap, globals, and statics — and that sharing is the danger. A data race is exact: two threads access the same memory location, at least one writes, and nothing synchronizes them — and that is undefined behavior (Lesson 19), because the compiler optimizes on the assumption it never happens. The intersection of sharing, mutation, and no synchronization is the enemy; remove any one and the race is gone, which is why immutable or unshared data is trivially safe — the same truth functional programming teaches from the other direction. When you must share and mutate, a std::mutex guarded by an RAII lock_guard/unique_lock (Lesson 05 again — the lock is never left held on an early return or exception) restores ordering at a real cost; misuse it and you trade a race for a deadlock, cured by a single global lock order. And false sharing — two threads writing different variables in one 64-byte cache line (Lesson 08) — destroys performance with no logical sharing at all, fixed not by a lock but by padding the variables onto separate lines.

Interview prompts