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.
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.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:
Unpack the three conditions, because correctness lives in the gaps between them:
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).
#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:
const (Lesson 16) is your evidence.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 lock | What it is | Use 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. |
#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.
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
std::thread destroyed while still owning a running thread calls std::terminate(). Always join() or detach() before it dies (RAII is enforcing this).lock_guard/unique_lock; never call lock/unlock directly.std::scoped_lock for multiple at once.Checkpoint exercise
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.
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
- Define a data race precisely, with all three conditions. (§2 — two threads access the same memory location, at least one access is a write, and there is no synchronization ordering them; the result is undefined behavior.)
- Why is a data race undefined behavior rather than just "the last writer wins"? (§2 — operations like
++aren't atomic, and the compiler is allowed to assume no races, so it may cache, reorder, or fuse accesses — optimizations valid only without races; UB is what licenses them, Lesson 19.) - Two threads only read the same shared variable — is that a race? (§2/§3 — no; a race requires at least one write, so immutable shared data is always safe, which is why removing mutation or sharing removes the race.)
- Why use
lock_guardinstead of callingmutex.lock()/unlock()? (§4 — RAII (Lesson 05): the destructor unlocks on every path including exceptions and early returns, so the mutex is never left locked; manual unlock is the unwinnable-by-discipline problem from Lesson 04.) - What causes deadlock and how do you prevent it? (§5 — two threads each holding a lock the other needs, classically two mutexes taken in opposite orders; prevent it with a single global lock order or
std::scoped_lockto take several at once deadlock-free.) - What is false sharing, and why won't a mutex fix it? (§6 — two threads writing different variables in the same 64-byte cache line (Lesson 08), so the line ping-pongs between cores though nothing is logically shared; the fix is padding/
alignas(64)to separate lines, not synchronization — there is no race to protect.) - How does this connect to functional programming's view of concurrency? (§3 — both conclude shared mutable state is the enemy; FP removes mutation/sharing by default so code is race-free and parallelizable, while C++ reaches the same conclusion via the cost of synchronization.)