Concurrency
C++11 introduced a full thread-and-synchronisation library and a memory model. The principal types — std::thread, std::mutex, std::condition_variable, std::atomic — are the C++ idiom for concurrency, with std::async and std::future for higher-level task-based programming. C++17 added parallel algorithms; C++20 added std::jthread, semaphores, latches, barriers, and coroutines. The library is substantial and the conventions for using it correctly are non-trivial; this page covers the surface a working C++ programmer encounters routinely.
The C++ memory model — the rules that govern inter-thread communication through atomic operations and ordinary memory — is the substrate on which all of the above rests. Programs that use mutexes correctly need not engage with the memory model directly; programs that use std::atomic non-trivially must understand the orderings.
std::thread and std::jthread
std::thread (C++11) is the principal thread type:
#include <thread>
#include <iostream>
void worker(int id) {
std::cout << "thread " << id << " running\n";
}
int main() {
std::thread t1(worker, 1);
std::thread t2(worker, 2);
t1.join();
t2.join();
}
The constructor starts a thread executing the supplied function with the supplied arguments. The thread must be either joined (the parent waits for completion via join) or detached (the thread runs independently; resources are reclaimed at thread exit). A std::thread whose destructor runs while the thread is joinable (neither joined nor detached) calls std::terminate.
std::jthread (C++20) is the joining thread: its destructor automatically joins the thread, and it integrates with std::stop_token for cooperative cancellation:
#include <thread>
#include <stop_token>
void cancelable_worker(std::stop_token stop) {
while (!stop.stop_requested()) {
do_work();
}
}
{
std::jthread t(cancelable_worker);
/* ... */
} // t.request_stop() and t.join() called automatically
std::jthread is the conventional choice in new code targeting C++20 or later: the automatic joining and the cancellation token together address the principal pitfalls of std::thread.
Mutexes and locks
std::mutex is the principal mutual-exclusion primitive:
#include <mutex>
std::mutex mtx;
int counter = 0;
void increment() {
std::lock_guard<std::mutex> lock(mtx);
++counter;
}
std::lock_guard is a RAII wrapper around the mutex: the constructor calls lock(), the destructor calls unlock(). The pattern guarantees release on every exit path, including exception propagation.
The standard provides several lock types and mutex variants:
| Type | Purpose |
|---|---|
std::mutex | Basic mutual exclusion. |
std::recursive_mutex | Same thread may lock multiple times; releases on as many unlocks. |
std::timed_mutex | try_lock_for with a duration. |
std::shared_mutex (C++17) | Multiple readers or one writer. |
std::lock_guard<M> | RAII; basic acquire/release. |
std::unique_lock<M> | RAII; admits unlock/relock, deferred locking, condition variables. |
std::shared_lock<M> (C++17) | RAII; acquires the read lock of a shared_mutex. |
std::scoped_lock<Ms...> (C++17) | RAII; acquires multiple mutexes deadlock-free. |
The conventional discipline:
- Use
std::lock_guardfor the simple case (acquire on entry, release on exit). - Use
std::unique_lockwhen the lock state needs to be flexible (condition variables require it; deferred or conditional locking). - Use
std::scoped_lockwhen more than one mutex must be held simultaneously; it acquires them in a deadlock-free order. - Use
std::shared_lockandstd::shared_mutexfor reader-writer patterns.
std::scoped_lock lock(mtx_a, mtx_b); // deadlock-free acquisition of both
The deadlock discipline
Two mutexes acquired in inconsistent order across threads is a classic deadlock. The defences:
- Acquire mutexes in a fixed order across the program.
- Use
std::scoped_lock(C++17) orstd::lock(older) to acquire several mutexes atomically. - Hold locks for the shortest time possible; never call user code (or any function that may itself lock) while holding a lock.
- Use higher-level constructs (
std::async, message-passing, immutable data) to avoid the need for multiple locks.
Condition variables
A condition variable lets a thread wait until another thread signals that some condition has become true:
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex q_mtx;
std::condition_variable q_cv;
std::queue<int> queue;
bool finished = false;
void producer() {
for (int i = 0; i < 100; ++i) {
{
std::lock_guard<std::mutex> lock(q_mtx);
queue.push(i);
}
q_cv.notify_one();
}
{
std::lock_guard<std::mutex> lock(q_mtx);
finished = true;
}
q_cv.notify_all();
}
void consumer() {
while (true) {
std::unique_lock<std::mutex> lock(q_mtx);
q_cv.wait(lock, [] { return !queue.empty() || finished; });
if (queue.empty() && finished) return;
int item = queue.front();
queue.pop();
lock.unlock();
process(item);
}
}
The contract:
- The waiting thread holds a
unique_lockon the same mutex that the signalling thread will hold. cv.wait(lock, predicate)atomically releases the lock and waits; on wake, it re-acquires the lock and tests the predicate. If the predicate is false, it waits again.- The predicate is essential —
cv.waitmay return spuriously (without a corresponding notify), and the signal may have been raised before another thread invalidated the condition. The loop on the predicate is the correctness defence.
The signalling thread modifies the condition under the lock and then calls notify_one or notify_all:
notify_onewakes one waiting thread (if any).notify_allwakes all waiting threads.
Atomics and the C++ memory model
std::atomic<T> provides atomic operations on T. The principal operations are atomic load, store, exchange, compare-and-swap, and arithmetic:
#include <atomic>
std::atomic<int> counter = 0;
void increment() {
counter.fetch_add(1, std::memory_order_relaxed);
}
int read_count() {
return counter.load(std::memory_order_acquire);
}
The standard’s memory orderings govern how atomic operations interact with surrounding (non-atomic) memory accesses:
| Order | Semantics |
|---|---|
memory_order_relaxed | No ordering with surrounding accesses; atomic only. |
memory_order_acquire | Reads after this operation are not reordered before it. (For loads.) |
memory_order_release | Writes before this operation are not reordered after it. (For stores.) |
memory_order_acq_rel | Both acquire and release. (For read-modify-write.) |
memory_order_seq_cst | Sequentially consistent — the strongest order. The default. |
The default memory_order_seq_cst is the easiest to reason about; relaxed forms admit performance gains on modern architectures but require careful analysis. The conventional discipline:
- Use the default
seq_cstunless measurement justifies otherwise. - Use
acquire/releasepairs for producer-consumer coordination. - Use
relaxedonly for counters and statistics that need not synchronise with other accesses.
Compare-and-swap
The compare-and-swap (CAS) primitive is the foundation of most lock-free data structures:
std::atomic<int> state = STATE_IDLE;
bool transition_to_running() {
int expected = STATE_IDLE;
return state.compare_exchange_strong(expected, STATE_RUNNING);
}
compare_exchange_strong(expected, desired) atomically replaces state with desired if state equals expected, returning true on success. On failure, expected is updated to state’s current value, which is useful for retry loops:
int current = state.load();
while (!state.compare_exchange_weak(current, current + 1)) {
/* current was updated by compare_exchange_weak; retry */
}
compare_exchange_weak may spuriously fail even when the comparison would succeed (typical on architectures with load-linked/store-conditional); it is conventionally used in retry loops, where the spurious failure simply causes another iteration.
Thread-local storage
Variables declared thread_local have one instance per thread:
thread_local int last_error = 0;
thread_local std::vector<int> per_thread_buffer;
The mechanism is the conventional way to implement per-thread caches, error states, and storage that should not be shared across threads. The principal trade-offs:
- Construction occurs the first time the thread accesses the variable (lazy initialisation) for non-constant initialisers.
- Destruction occurs at thread exit, in reverse order of construction.
- Access has a small per-platform cost (typically a register relative load, indistinguishable from local-variable access).
thread_local storage participates in C++ object lifetime: the destructor runs at thread exit, RAII wrappers work correctly.
std::async, std::future, std::promise
The <future> library provides task-based parallelism. The principal types:
std::future<T>— a handle to a result that will be produced eventually.std::promise<T>— the producer side of a future.std::async(fn, args...)— runsfn(args...)in a new task, returning afuture.
#include <future>
std::future<int> fut = std::async(std::launch::async,
[](int x) { return x * x; }, 7);
int result = fut.get(); // blocks until the task completes; returns 49
std::async may run the task on a new thread, on a thread-pool thread, or synchronously when get() is called. The launch policy std::launch::async forces a new thread; std::launch::deferred defers execution until get(); the default admits either.
std::promise/std::future decouple the producer and consumer:
std::promise<int> prom;
std::future<int> fut = prom.get_future();
std::thread producer([&prom] {
prom.set_value(compute());
});
int result = fut.get(); // blocks until set_value
producer.join();
The mechanism is the conventional way to model “this thread will eventually produce a value the other thread needs”.
Parallel algorithms
C++17 added execution policies to many of the algorithms in <algorithm>:
#include <execution>
#include <algorithm>
std::sort(std::execution::par, v.begin(), v.end());
std::for_each(std::execution::par_unseq, v.begin(), v.end(), do_work);
The policies:
| Policy | Semantics |
|---|---|
std::execution::seq | Sequential. The default behaviour. |
std::execution::par | Parallel — work distributed across threads. |
std::execution::par_unseq | Parallel and unsequenced; admits SIMD and reordering within a thread. |
std::execution::unseq | (C++20) Unsequenced; SIMD only, single-threaded. |
The implementation distributes work across the available cores; the speedup depends on the per-element work and on the platform. The principal practical consequence is that a sequential algorithm can be parallelised by adding a single argument; the conventional discipline is to measure rather than to assume the speedup is worth the overhead.
Coroutines briefly
C++20 introduced coroutines — functions that may suspend and resume. A function becomes a coroutine if its body contains co_await, co_yield, or co_return:
generator<int> count_to(int n) {
for (int i = 0; i < n; ++i) {
co_yield i;
}
}
for (int v : count_to(10)) {
std::cout << v << ' ';
}
Coroutines are the language-level mechanism for asynchronous programming, generators, and lazy sequences. The full mechanism is substantial: a coroutine’s return type must satisfy a promise type protocol that the standard library does not yet provide a default implementation of. The proposed std::generator (C++23) is the first standard generator type; library support for asynchronous coroutines (task<T>, awaitable) is provided by libraries like cppcoro and the (proposed) std::execution.
For most working programmers, coroutines remain a topic to learn when needed; they are not yet the conventional choice for concurrency in C++.
Common patterns
Producer-consumer queue
The conventional concurrency primitive is a thread-safe queue:
template <typename T>
class BlockingQueue {
public:
void push(T item) {
std::lock_guard lock(mtx_);
queue_.push(std::move(item));
cv_.notify_one();
}
T pop() {
std::unique_lock lock(mtx_);
cv_.wait(lock, [this] { return !queue_.empty(); });
T item = std::move(queue_.front());
queue_.pop();
return item;
}
private:
std::mutex mtx_;
std::condition_variable cv_;
std::queue<T> queue_;
};
Once-initialisation
std::call_once and std::once_flag admit thread-safe one-time initialisation:
#include <mutex>
std::once_flag flag;
void ensure_initialised() {
std::call_once(flag, [] { do_initialisation(); });
}
The function is invoked exactly once, even if multiple threads call ensure_initialised concurrently.
Singleton initialisation via static local
Database &get_database() {
static Database instance; // C++11: thread-safe initialisation
return instance;
}
C++11 and later guarantee that the initialisation of a static local variable is thread-safe; concurrent calls to get_database initialise the database exactly once.
A note on choosing concurrency primitives
The conventional progression for new concurrent code:
- Avoid concurrency where possible. Single-threaded code is simpler.
- Prefer task-based abstractions —
std::async,std::future, parallel algorithms — over manual thread management. - Use thread pools and queues for explicit thread management.
- Use mutexes for shared state. The well-understood path.
- Use atomics for fine-grained state — counters, flags, queue indices.
- Use lock-free structures only where measurement justifies them. Substantial complexity for sometimes-marginal gains.
The progression is the same as in C and other systems languages; C++‘s addition is the breadth of the standard-library support, which makes the simpler approaches more attractive.