references/concurrency.md
Concurrency
Below the sender layer: threads, locks, atomics, and the memory model. Most code should use the highest-level tool that fits, and reach down only when measurement says to.
The ladder
Pick the highest rung that solves the problem.
| Rung | Use when |
|---|---|
A parallel algorithm (std::execution::par) | The work is a loop over a container. |
| Senders, or a task library | The work is a graph: several contexts, cancellation, fan-out. |
std::jthread + a queue | A small number of long-lived workers. |
std::mutex and friends | Shared mutable state that genuinely must be shared. |
std::atomic | A single word, and profiling says the mutex is the bottleneck. |
| Hand-rolled lock-free | Never. Use a reviewed library. |
Each rung down costs correctness margin. The bottom rung fails as silent data corruption rather than a crash, which is why it is not worth the microseconds unless you have measured them.
Threads
std::jthread, never std::thread.
std::jthread worker([](std::stop_token stop) {
while (!stop.stop_requested()) { poll_once(); }
});
// destructor requests stop, then joins
std::thread's destructor calls std::terminate if the thread was neither joined nor detached, so a single early return between construction and join() is a crash. jthread requests stop and joins in its destructor, which is the behaviour you wanted every time.
detach() is almost always wrong. A detached thread can outlive everything it captured, including main. If you need fire-and-forget, use an async scope that you can join() at shutdown.
Cancellation is cooperative. std::stop_token is the standard channel: poll it in loops, or register a std::stop_callback to interrupt a blocking wait. condition_variable_any::wait takes a stop token directly.
Mutual exclusion
Never call lock() and unlock() by hand. An exception or an early return between them leaves the mutex held forever.
std::lock_guard guard(mtx); // one mutex, whole scope
std::scoped_lock guard(mtx_a, mtx_b); // several, deadlock-free ordering
std::unique_lock lock(mtx); // needs to unlock early, or move, or wait on a cv
std::shared_lock reader(shared_mtx); // reader side of a shared_mutex
std::scoped_lock is the answer to lock-ordering deadlocks: it acquires several mutexes with an algorithm that cannot deadlock. Taking two mutexes with two lock_guards in different orders in different functions is the classic deadlock, and it is invisible in review.
Keep the critical section short and call nothing you do not control inside it. Calling a user callback, allocating, or logging under a lock is how a lock becomes a bottleneck and how lock-order bugs get introduced by someone else's code.
shared_mutex is not free. It only wins when reads are frequent and long. For a short read, the shared-mutex bookkeeping costs more than the plain mutex it replaced. Measure.
condition_variable always takes a predicate. The form without one is wrong, because of spurious wakeups:
cv.wait(lock, [&] { return !queue.empty() || done; }); // correct
cv.wait(lock); // wakes for no reason, eventually
Notify with the lock released where you can, and use notify_one unless every waiter genuinely needs to run.
Atomics and the memory model
A data race is undefined behaviour, not a wrong value. Two threads, one writing and one reading the same non-atomic object with no synchronisation, means the compiler is allowed to assume it cannot happen. The symptom then appears somewhere unrelated to the bug, which is why "it only happens in release under load" is the signature of a race.
Default to std::memory_order_seq_cst, which is what std::atomic's operations use if you say nothing. It is the only order that behaves the way people reason. Relaxing it is a measured optimisation with a proof obligation.
| Order | What it buys | Use for |
|---|---|---|
seq_cst | A single total order all threads agree on | The default. Everything, until profiling objects. |
acquire/release | A happens-before edge between the releasing store and the acquiring load | Handing ownership of data across threads: publish a pointer, then read it. |
relaxed | Atomicity only, no ordering | Counters and statistics whose value nobody acts on. |
std::atomic_ref (C++20) applies atomic operations to an object you do not own, which is how you make one field of an existing struct atomic without changing its layout. C++20 also adds wait/notify_one/notify_all on atomics, so a thread can block on a value change without a condition variable.
Coordination primitives (C++20)
std::latch: a one-shot countdown. Threadsarrive_and_wait(); when the count
reaches zero everyone proceeds. Startup barriers and fork-join.
std::barrier: reusable, with an optional completion function run once per
phase. Iterative algorithms with a sync point per round.
std::counting_semaphore: bounds concurrency. The right tool for "at most eight
of these in flight", where a mutex would serialise completely.
These are usually clearer and faster than the equivalent mutex plus condition variable, and they say what they mean at the declaration.
False sharing
Two threads writing to different variables on the same cache line contend as if they shared a variable. It is a common cause of parallel code that gets slower with more threads.
struct alignas(std::hardware_destructive_interference_size) Counter {
std::atomic<std::uint64_t> value{0};
};
std::array<Counter, kThreads> counters; // one per line, no contention
The general fix is to accumulate in a thread-local and combine once at the end, which is also why bulk_chunked exists in the sender model.
thread_local
Useful for per-thread scratch buffers and accumulators. Two caveats: destructors run at thread exit, so a thread_local holding a reference to a shared object is a shutdown-order problem; and on a thread pool a thread_local is per worker, not per task, so it survives across unrelated work items.
Parallel algorithms
std::sort(std::execution::par, values.begin(), values.end());
The cheapest parallelism available when the work is already an algorithm. par_unseq additionally permits vectorisation, which means the body must not lock or allocate. Implementations vary in what they actually parallelise, and libstdc++ needs Intel TBB linked for it to do anything, so verify you got what you asked for rather than assuming.
Testing concurrent code
- ThreadSanitizer in CI. It finds races that no amount of stress testing
reliably reproduces. Not compatible with ASan; run them as separate builds.
- Make the schedule deterministic where you can. Inject the executor so a test
can run everything inline. A test that only fails one time in fifty is worse than no test, because it gets rerun until it passes.
- Test cancellation explicitly. Request stop mid-flight and assert the thing
actually stopped and cleaned up. This path is almost never exercised by accident and almost always broken.