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::threadobject must be eitherjoin()ed ordetach()ed before it is destroyed. Otherwise the destructor callsstd::terminate(). This is a real bug in every codebase that has not yet moved tostd::jthread.detach()is almost always wrong. You lose the ability to observe completion, exceptions vanish, and ifmainreturns before the detached thread does, it is UB. Usedetachonly for genuine fire-and-forget daemons where you have thought about the exit story.Passing references to a thread: use
std::ref(x)explicitly. Barexis passed by value into the thread’s argument tuple, even if the worker signature takesT&. 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 |
|---|---|---|
|
The default. Fast, non-recursive, non-reentrant. |
Undefined behavior if the same thread locks twice. |
|
Reader-writer lock: many readers, one writer. |
Slower than plain |
|
Same thread can lock N times, must unlock N times. |
Almost always a design smell. If you need recursion, restructure. |
|
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 |
|---|---|
|
Single mutex, entire scope. Simplest. Cannot be moved. |
|
You need to unlock/relock, hand off to |
|
Multiple mutexes at once. Uses deadlock-avoidance algorithm. Always use this over hand-rolled dual-lock. |
|
Reader side of |
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¶
Always pass a predicate to
wait. Thewhile (!pred) cv.wait(lk)form is what the two-arg overload compiles to. No exceptions.The state and the CV share the same mutex. Reading/writing
readyoutside the lock is a data race.notify_onevsnotify_all:notify_oneif only one waiter can make progress (e.g., producer-consumer);notify_allif the wake condition may apply to multiple waiters simultaneously.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.
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 thefutexsyscall for slow-path sleeping. Fast path is a user-space CAS. File 05 covers futex.macOS / libc++:
os_unfair_lockin newer libc++ (Apple’s fork); older code usespthread_mutex_ton 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.