Concurrency and Threads
7 pages · ~5 min✓ Reviewed
Builds on Templates and Modern C++. Next up: Undefined Behavior and Debugging.
Threads and the Concurrency Mental Model
1. Starting a thread
std::threadruns the callable immediately on construction- Arguments are copied or moved into the thread
- To pass a reference, wrap it in std::ref
- A thread object is move-only
hardware_concurrency()is a core-count hint, may return 0
void inc(int& n) { ++n; }
int n = 0;
std::thread t(inc, std::ref(n));
t.join(); // n == 1without std::ref, the thread gets a copy
2. Lifetime and cancel
| std::thread | std::jthread (C++20) | |
|---|---|---|
| Joinable at destruction | std::terminate | joins automatically |
| Fix | join() or detach() | none needed |
| Cancel | none built in | stop_token + request_stop() |
std::jthread w([](std::stop_token st) {
while (!st.stop_requested()) { /* work */ }
});
w.request_stop(); // dtor then joinscooperative: the thread must check the token
3. Exceptions
- Exception escaping the thread function calls std::terminate
- Catch inside the thread, or
- Store
std::current_exception()in an exception_ptr, thenrethrow_exceptionin the joiner
Every thread must be joined or detached before destruction. Prefer jthread.
Mutexes and Lock Wrappers
1. Lock Wrappers
std::mutexhaslock()andunlock(): never call by hand- Use an RAII wrapper: unlocks on every exit, even exceptions
| lock_guard | unique_lock | scoped_lock | |
|---|---|---|---|
| Mutexes | 1 | 1 | many (C++17) |
| Movable | no | yes | no |
Early unlock, defer_lock | no | yes | no |
cv.wait | no | required | no |
2. Many Locks, Many Readers
std::scoped_lock lk(m1, m2); // deadlock-safe std::unique_lock u(m1, std::defer_lock); std::shared_lock rd(rw); // many readers std::unique_lock wr(rw); // one writer
rw is a std::shared_mutex
recursive_mutex: same thread may relock
Condition Variables
1. What a Condition Variable Does
std::condition_variable: a thread sleeps until another thread signals a change- Waiter holds a
unique_lockon the mutex guarding the condition waitreleases the mutex while asleep, reacquires it before returning
- Lock mutexunique_lock
- Check predicatefalse? sleep
- Sleepmutex released
- Wake + relockrecheck predicate
2. Waiting and Notifying
- Always wait with a predicate: it handles spurious wakeups and lost notifications
- Change the state under the mutex, then notify
// waiter std::unique_lock lk(m); cv.wait(lk, [&]{ return ready; }); // signaller { std::lock_guard g(m); ready = true; } cv.notify_one();
predicate form = while(!ready) cv.wait(lk)
| Call | Wakes | Use when |
|---|---|---|
notify_one() | one waiter | one thread can proceed |
notify_all() | every waiter | several threads may proceed |
Atomics and Memory Order Basics
1. What std::atomic Gives You
std::atomic<T>: single operations are indivisible, no data race- Ops:
loadstoreexchangefetch_addcompare_exchange_strong/weak - weak may fail spuriously, so use it in a retry loop
- strong fails only when the value really differs
std::atomic<int> x{0};
int old = x.load();
while (!x.compare_exchange_weak(old, old * 2)) {
// failure reloads old, then retry
}CAS retry loop
2. Memory Order
| Order | Guarantee | Use |
|---|---|---|
seq_cst (default) | one global order of all atomic ops | safest choice |
relaxed | atomicity only, no ordering | plain counters |
release store | earlier writes are published | pairs with acquire |
acquire load | sees writes before the release | reader side |
- Write dataplain write
- Store releaseflag = true
- Load acquiresees flag
- Read datasees the write
Async, Futures and Promises
1. std::async and future
std::async(fn, args)returns a std::future<T>get()blocks until ready, rethrows the task's exception, works once only
| Policy | Runs | When |
|---|---|---|
| default | may be deferred | implementation's choice |
launch::async | new thread | forced |
launch::deferred | calling thread | lazily, on get() |
A future from async blocks in its destructor until the task ends. Discard it and the call is synchronous.
2. promise and packaged_task
std::promise<int> p;
std::future<int> f = p.get_future();
std::thread t([&p]{ p.set_value(42); });
int v = f.get(); // blocks
t.join();writer holds the promise, reader holds the future
set_valueorset_exceptionsets the result;get_future()is the reader side
Data Races, Deadlocks and Cheat Sheet
1. Races and Deadlocks
- Data race: same memory, no synchronization, at least one write
- It is undefined behaviour, not just a wrong value
- Deadlock needs circular wait on two or more locks
- A holds m1
- A wants m2held by B
- B holds m2
- B wants m1held by A
std::scoped_lock lk(m1, m2);
fix: lock both at once, or use one fixed global lock order
2. Lock Discipline and Detection
g++ -std=c++20 -fsanitize=thread -pthread a.cppThreadSanitizer; -pthread works on GCC and Clang
3. Cheat Sheet
| Need | Use |
|---|---|
| Thread | jthread over thread |
| Locking | scoped_lock |
| Waiting |
Check yourself
Does other() run while work runs?
- No. The returned future is a temporary, and its destructor blocks until work finishes.
- Keep it:
auto f = std::async(...).
std::async(std::launch::async, work); other();
What happens when run() returns?
std::terminate, because t is still joinable at destruction.- Call
t.join()first, or usestd::jthread.
void run() {
std::thread t(work);
}Thread A locks m1 then m2; thread B locks m2 then m1. What can go wrong, and what fixes it?
- Deadlock: each holds one mutex and waits for the other (circular wait).
- Lock both together with
std::scoped_lock lk(m1, m2);.