Threads and Synchronization Primitives

“Threads are cheap; synchronization is expensive. Get the synchronization right first, then worry about how many threads you have.”

This file covers the C++ standard-library concurrency primitives at a working practitioner’s level. It assumes you have finished file 01 (memory model). If you have not, close this file and go back — the concepts below are only meaningful once you can read a happens-before graph.


std::thread — the basic thread

#include <thread>

void worker(int id, const std::string& tag) { /* ... */ }

int main() {
    std::thread t(worker, 42, "hello");
    t.join();      // wait for it to finish
    // or t.detach();  // run to completion, no join
}

Critical rules:

  • A std::thread object must be either join()ed or detach()ed before it is destroyed. Otherwise the destructor calls std::terminate(). This is a real bug in every codebase that has not yet moved to std::jthread.

  • detach() is almost always wrong. You lose the ability to observe completion, exceptions vanish, and if main returns before the detached thread does, it is UB. Use detach only for genuine fire-and-forget daemons where you have thought about the exit story.

  • Passing references to a thread: use std::ref(x) explicitly. Bare x is passed by value into the thread’s argument tuple, even if the worker signature takes T&. This surprises everyone once.

std::jthread (C++20) — use this by default

#include <thread>

void worker(std::stop_token st) {
    while (!st.stop_requested()) {
        // do work
    }
}

int main() {
    std::jthread t(worker);
    // destructor calls request_stop() then join(). No terminate footgun.
}

std::jthread gives you (1) auto-join on destruction, (2) std::stop_token for cooperative cancellation. Default to jthread for anything new. std::thread remains for pre-C++20 code and edge cases where you specifically need detach behavior.


std::mutex and its family

Type

When to use

Notes

std::mutex

The default. Fast, non-recursive, non-reentrant.

Undefined behavior if the same thread locks twice.

std::shared_mutex (C++17)

Reader-writer lock: many readers, one writer.

Slower than plain mutex; only wins under heavy read contention.

std::recursive_mutex

Same thread can lock N times, must unlock N times.

Almost always a design smell. If you need recursion, restructure.

std::timed_mutex

Lock with a timeout.

Rare; usually a mistake. If you might time out, use a lock-free structure or a condition variable.

On the recursive mutex. If your code needs one, you have a critical section whose scope is unclear. This means two engineers will disagree about what invariant it protects, which means a race is inevitable. Refactor. The only legitimate use I have seen is retrofitting thread-safety into a legacy API that was designed re-entrant.

RAII lock wrappers — use these, never lock() / unlock() directly

Wrapper

When to use

std::lock_guard<M>

Single mutex, entire scope. Simplest. Cannot be moved.

std::unique_lock<M>

You need to unlock/relock, hand off to condition_variable::wait, or defer/try locking.

std::scoped_lock<Ms...> (C++17)

Multiple mutexes at once. Uses deadlock-avoidance algorithm. Always use this over hand-rolled dual-lock.

std::shared_lock<M>

Reader side of shared_mutex.

The classic multi-mutex deadlock

std::mutex m1, m2;

// Thread A:
{ std::lock_guard lk1(m1); std::lock_guard lk2(m2); /* ... */ }

// Thread B:
{ std::lock_guard lk1(m2); std::lock_guard lk2(m1); /* ... */ }
// A holds m1, waits for m2. B holds m2, waits for m1. Deadlock.

Fix: always acquire in a consistent global order, or use scoped_lock:

std::scoped_lock lk(m1, m2);  // deadlock-free, uses std::lock's algorithm

Memorize scoped_lock. It is the single most valuable C++17 concurrency addition.


std::condition_variable — the spurious-wakeup trap

A condition_variable (CV) lets one thread wait for another to change some state. It is not a message queue; it is a synchronization primitive for “go to sleep until this condition holds.”

The wrong way (do not do this)

std::mutex m;
std::condition_variable cv;
bool ready = false;

// Waiter:
std::unique_lock lk(m);
cv.wait(lk);           // BUG: no predicate
// use `ready` here — but `ready` may still be false!

Why broken: CV wakes can be spurious — the OS is permitted to wake wait() for any reason, including “because.” Without a predicate you may read ready == false and use uninitialized data.

The right way (memorize this)

std::mutex m;
std::condition_variable cv;
bool ready = false;

// Waiter:
{
    std::unique_lock lk(m);
    cv.wait(lk, [&]{ return ready; });   // predicate re-checked on every wake
    // now ready == true, use shared state
}

// Notifier:
{
    std::lock_guard lk(m);
    ready = true;
}
cv.notify_one();   // notify AFTER unlocking (or while still holding — both correct, unlocking first is a tiny perf win)

The rules

  1. Always pass a predicate to wait. The while (!pred) cv.wait(lk) form is what the two-arg overload compiles to. No exceptions.

  2. The state and the CV share the same mutex. Reading/writing ready outside the lock is a data race.

  3. notify_one vs notify_all: notify_one if only one waiter can make progress (e.g., producer-consumer); notify_all if the wake condition may apply to multiple waiters simultaneously.

  4. You do not need to hold the lock when calling notify_*. Some codebases release the lock before notifying to avoid the notified thread waking up and immediately blocking on the lock. Micro-optimization; both are correct.

The producer-consumer canonical example

#include <condition_variable>
#include <mutex>
#include <queue>

std::mutex m;
std::condition_variable cv;
std::queue<int> q;
bool done = false;

void producer() {
    for (int i = 0; i < 100; ++i) {
        { std::lock_guard lk(m); q.push(i); }
        cv.notify_one();
    }
    { std::lock_guard lk(m); done = true; }
    cv.notify_all();
}

void consumer() {
    while (true) {
        std::unique_lock lk(m);
        cv.wait(lk, [&]{ return !q.empty() || done; });
        if (q.empty() && done) return;
        int x = q.front(); q.pop();
        lk.unlock();
        process(x);
    }
}

Study this until you can write it from memory. It is the ancestor of every thread pool, every job queue, every event loop.


std::latch and std::barrier (C++20)

Two simple synchronization primitives you should know for study questions and correct code.

std::latch — count-down, one-shot

#include <latch>

std::latch start_signal(1);
std::latch done_count(N);

for (int i = 0; i < N; ++i) {
    std::jthread([&]{
        start_signal.wait();     // all workers unblock together
        work();
        done_count.count_down();
    });
}
start_signal.count_down();       // release all
done_count.wait();               // main waits for all to finish

Use when: N workers must start together, or main must wait for N to complete once. One-shot only; not reusable.

std::barrier — phased, reusable

#include <barrier>

std::barrier sync_point(N, []() noexcept { /* completion function */ });

// Each of N threads:
for (int phase = 0; phase < num_phases; ++phase) {
    do_phase(phase);
    sync_point.arrive_and_wait();   // all threads sync here, then next phase
}

Use when: many threads work in lockstep across multiple phases (e.g., BSP-style parallel algorithms, iterative solvers). Barriers are reusable across phases; latches are not.


Thread-local storage

thread_local int per_thread_counter = 0;

void worker() {
    per_thread_counter++;   // each thread has its own copy
}

Rules:

  • Per-thread copy, initialized on first access from that thread.

  • Slower than plain locals; do not use “just in case.”

  • Destructor runs on thread exit.

  • Do not use in a thread pool where the same thread runs many tasks and you expect state to reset between them — it will not.

Useful for: per-thread RNG state, per-thread scratch buffer to avoid allocator contention, per-thread PMR memory resource.


Common bugs (study questions and production bugs both)

1. Notify while holding the lock (subtle)

{ std::lock_guard lk(m); ready = true; cv.notify_one(); }

Correct, but the waiter wakes up and immediately blocks on the mutex you still hold. Tiny perf hit. Idiomatic fix:

{ std::lock_guard lk(m); ready = true; }
cv.notify_one();

Both are correct. Only the second is optimal. study partners ask about this.

2. Spurious wakeup without predicate

Already covered. Every wait gets a predicate. Every one. No exceptions.

3. Lost wakeup (“notify before wait”)

// Thread A:
cv.notify_one();
// Thread B:
cv.wait(lk, pred);   // never wakes — the notify happened before we started waiting

Fix: the predicate. If pred is already true when wait is called, wait returns immediately without blocking. This is exactly why you always use the predicate form.

4. unique_lock moved from, then unlocked

std::unique_lock lk1(m);
auto lk2 = std::move(lk1);   // lk1 is now moved-from
lk1.unlock();                // UB: unlocking a moved-from lock

Modern compilers may not warn. Sanitizers may not catch this. Discipline required.

5. Destroying a thread’s local variable that the thread still uses

void foo() {
    std::string s = "hello";
    std::thread t([&s]{ std::this_thread::sleep_for(1s); std::cout << s; });
    t.detach();
}   // s destroyed; detached thread reads freed memory

Use jthread (auto-joins) or capture by value.

6. Locking order inconsistency

Covered above. scoped_lock fixes this.

7. Sharing a shared_ptr<T> control block from multiple threads without atomic ops

The control block (ref count) is atomic. The pointee is not. If two threads mutate the pointee simultaneously, that is your race, not a shared_ptr race. std::atomic<std::shared_ptr<T>> (C++20) exists for the case where you also want atomic pointer swap.


What actually happens under std::mutex on your platform

(You will need this for systems studies. Cite it.)

  • Linux / glibc: pthread_mutex_t, which uses the futex syscall for slow-path sleeping. Fast path is a user-space CAS. File 05 covers futex.

  • macOS / libc++: os_unfair_lock in newer libc++ (Apple’s fork); older code uses pthread_mutex_t on top of Mach ports. Fast path is a user-space CAS + xchg; slow path via kernel wait.

  • Windows / MSVC: SRWLock (slim reader-writer lock). Similar fast-path/slow-path structure.

All of these are lock-free on the uncontended path. That is the whole reason std::mutex is not “slow” in the mythological sense — uncontended it is ~10-20 ns.


What most people get wrong

They write cv.wait(lk) without a predicate and blame the OS when they see a bug in production. They use std::thread in 2026 without knowing std::jthread exists. They lock two mutexes in inconsistent order across two functions and deadlock only under load. They use recursive_mutex because “it is easier” — the four times I have seen this in code review, three of them had a race.

The fix, again, is discipline plus tooling. Compile with -fsanitize=thread for every debug build. Use jthread and scoped_lock by default. Predicate every wait. These four habits eliminate ~90% of threading bugs from ~90% of code.


Nav: ← C++ memory model · Async, futures, coroutines →