An Introduction to Multithreading in C++20
An Introduction to Multithreading in C++20 - Anthony Williams - CppCon 2022
Source: An Introduction to Multithreading in C++20 - Anthony Williams - CppCon 2022
Choosing a concurrency model
There are two fundamental reasons to use multiple threads:
- Scalability: finish work faster by running parts of it in parallel.
- Separation of concerns: keep conceptually independent activities independent, even when raw performance is not the priority.
The reason for introducing concurrency should drive the design.
Scaling and Amdahl’s law
If the goal is scalability, Amdahl’s law limits the maximum possible speedup:
\[S = \frac{1}{(1-p) + \frac{p}{n}}\]where:
- $S$ is the maximum speedup multiplier;
- $p$ is the fraction of the program that can run in parallel; and
- $n$ is the number of processors.
The sequential portion eventually dominates, however many processors are added.
Parallel algorithms
Many standard-library algorithms accept an execution policy:
1
2
3
4
5
6
7
std::vector<MyData> data = /* ... */;
std::sort(
std::execution::par,
data.begin(),
data.end(),
MyComparator{});
Also look for consecutive operations that can be fused. For example, a parallel transform followed by a parallel reduction makes two passes and introduces a boundary between them:
1
2
std::transform(std::execution::par, /* ... */);
std::reduce(std::execution::par, /* ... */);
std::transform_reduce can combine that work:
1
std::transform_reduce(std::execution::par, /* ... */);
Independent tasks
Another scalable design is to divide the work into many independent tasks and submit them to a thread pool.
1
2
3
4
5
6
thread_pool pool;
void foo() {
execute(pool, [] { do_work(); });
execute(pool, [] { do_other_work(); });
}
The pool controls the worker count while the application exposes enough independent work to keep those workers busy.
Cooperative cancellation
Long-running operations often need a cancel button, timeout, shutdown path, or another way to stop early. Forcibly terminating a thread is undesirable: it can interrupt the thread while invariants are broken, locks are owned, or resources are only partly updated.
C++20 instead provides cooperative cancellation through std::stop_source, std::stop_token, and std::stop_callback. It is purely cooperative: if the target operation never checks or registers for cancellation, a stop request does nothing to that operation.
- Create a
std::stop_source. - Obtain its
std::stop_token. - Pass the token to a new thread or task.
- Call
source.request_stop()when the operation should stop. - Have the operation periodically call
token.stop_requested(). - Exit or clean up when cancellation is requested.
1
2
3
4
5
6
7
8
9
10
11
12
void stoppable_func(std::stop_token token) {
while (!token.stop_requested()) {
do_stuff();
}
}
void stopper(std::stop_source source) {
while (!done()) {
do_something();
}
source.request_stop();
}
Custom Cancellation with std::stop_callback
Polling is not useful while a task is blocked inside another API. std::stop_callback can adapt a stop token to an existing cancellation mechanism, such as cancellation of asynchronous I/O:
1
2
3
4
5
6
7
Data read_file(std::stop_token token, std::filesystem::path filename) {
auto handle = open_file(filename);
std::stop_callback callback(token, [&] {
cancel_io(handle);
});
return read_data(handle); // blocking
}
The callback remains registered for its lifetime. A stop request invokes it so that the blocking operation can be interrupted by the mechanism understood by that API.
The callback is invoked in the thread that calls request_stop().
Starting and managing threads
For a new C++20 codebase:
- use
std::jthreadfor directly managed threads in almost all cases - use
std::asyncwhen you want a result - use
std::threadonly if you have no choice
The advantage of std::jthread is ownership-safe cleanup plus integrated cooperative cancellation.
std::jthread overview
std::jthread x{Callable, Args...} do:
- Create a new
std::stop_source-src - Create a new thread running
Callable(src.get_token(), Args...)ifCallablecan be invoked with astd::stop_tokenas its first argument, otherwise runCallable(Args...)
Destructor semantics
The destructor will request stop and join the thread.
1
2
3
4
5
6
7
8
9
10
11
12
13
void thread_func(
std::stop_token token,
std::string arg1,
int arg2) {
while (!token.stop_requested()) {
do_stuff(arg1, arg2);
}
}
void foo(std::string text) {
std::jthread thread{thread_func, text, 42};
do_stuff();
} // requests stop, then joins
The cancellation API is:
1
2
3
4
5
std::jthread thread{some_callable};
std::stop_source source = thread.get_stop_source();
std::stop_token token = thread.get_stop_token();
bool first_request = thread.request_stop(); // equivalent to source.request_stop()
Latches
std::latch is a single-use counter that lets threads wait until its count reaches zero:
- Construct it with a non-zero count.
- One or more threads decrease the count.
- Other threads may wait for it to be signalled.
- When the count reaches zero, the latch is permanently signalled and all waiting threads wake.
| Operation | Meaning |
|---|---|
std::latch latch{count}; | Create a latch with the initial count. |
latch.count_down(); | Decrease the count and trigger the latch if it reaches zero. |
latch.wait(); | Wait until the latch is triggered. |
latch.arrive_and_wait(); | Count down, then wait for the latch. |
Coordinating multithreaded tests
Latches are also useful for arranging a test so several threads begin the interesting operation together:
- Set up the test data.
- Construct a latch whose count is the number of test threads.
- Start those threads.
- Make the first action in each thread
test_latch.arrive_and_wait(). - Once every thread reaches the latch, they are all unblocked to run the test code.
This does not prove every possible interleaving, but it can reliably create useful contention in a test.
Barriers
std::barrier is reusable. It coordinates participating threads in a sequence of phases:
- Construct a barrier with a non-zero expected count and, optionally, a completion function.
- One or more threads arrive during a phase.
- Some or all of them wait for the phase to complete.
- When the count reaches zero, one participating thread runs the completion function, the waiting threads are released, and the count resets for the next phase.
Its main operations are:
| Operation | Meaning |
|---|---|
std::barrier barrier{count, completion}; | Create a reusable barrier with an expected count and completion function. |
auto token = barrier.arrive(); | Decrease the count and obtain an arrival token. The last arrival triggers phase completion. |
barrier.wait(std::move(token)); | Wait for the phase represented by the token to complete. |
barrier.arrive_and_wait(); | Arrive and wait in one operation. |
barrier.arrive_and_drop(); | Arrive without waiting and permanently reduce the expected count for later phases. |
All participants must follow a compatible arrival protocol. If one exits a loop while the others still expect it, those threads can wait forever; a departing participant can use arrive_and_drop() when appropriate.
std::barrier::arrival_token
An arrival_token is an opaque, move-only receipt identifying the barrier phase in which arrive() happened.
1
2
3
auto token = barrier.arrive();
do_independent_work();
barrier.wait(std::move(token));
This will wait until the phase represented by token completes.
We want to use arrival_token to avoid the ABA problem: if a thread arrives, does some work, and then waits, the barrier may have completed one or more phases in the meantime. The token ensures that the wait is for the same phase as the arrival.
Futures, promises, and tasks
Futures provide a one-shot transfer of a value or exception between threads. Several producers can create the shared state behind a std::future<T>:
std::asynclaunch a task that returns a valuestd::promiseexplicit set a valuestd::packaged_taskwrap a task that returns a value
std::future<T>
| Operation | Meaning |
|---|---|
std::future<T> future; | Create an empty future with no shared state. |
future.valid() | Check whether it refers to shared state. |
future.wait() | Block until the result is ready. |
future.wait_for(duration) | Wait up to a relative duration. |
future.wait_until(time_point) | Wait until an absolute time point. |
future.get() | Wait, then retrieve the value or rethrow the stored exception. |
1
2
3
4
5
6
7
8
9
10
11
12
void blocking(std::future<int> future) {
future.wait(); // `get()` already waits, so `wait()` is optional
do_stuff(future.get());
}
void polling(std::future<int> future) {
using namespace std::chrono_literals;
if (future.wait_for(0s) == std::future_status::ready) { // this is a non-blocking check
do_stuff(future.get());
}
}
std::promise<T>
A promise is the explicit producer end of the shared state:
| Operation | Meaning |
|---|---|
std::promise<T> promise; | Create a promise with a new shared state. |
promise.get_future() | Obtain that state’s future; this can be done only once. |
promise.set_value(value) | Make the value ready. |
promise.set_exception(ptr) | Make an exception ready. |
Passing a value:
1
2
3
4
5
6
7
8
9
10
std::promise<MyData> promise;
std::future<MyData> future = promise.get_future();
std::jthread consumer{[future = std::move(future)]() mutable {
do_stuff(future.get());
}};
std::jthread producer{[&promise] {
promise.set_value(make_data());
}};
Passing an exception uses the same channel:
1
2
3
4
5
6
7
8
9
10
11
std::promise<MyData> promise;
std::future<MyData> future = promise.get_future();
std::jthread consumer{[future = std::move(future)]() mutable {
do_stuff(future.get()); // rethrows MyException
}};
std::jthread producer{[&promise] {
promise.set_exception(
std::make_exception_ptr(MyException{}));
}};
Launching a result-producing task with std::async
With an explicit std::launch::async policy, std::async launches func(arg1, arg2) asynchronously and returns a future for the call’s result:
1
2
3
4
5
6
7
auto future = std::async(
std::launch::async,
func,
arg1,
arg2);
auto result = future.get();
If the future is destroyed before get() is called, the destructor blocks until the asynchronous operation completes.
If the launch policy is std::launch::deferred, the call is deferred until get() or wait() is called, and it runs in the calling thread.
Shared futures
Use std::shared_future<T> when multiple threads must receive the same result. It is copyable, and each copy can call get():
1
2
3
4
5
6
std::promise<MyData> promise;
std::shared_future<MyData> result =
promise.get_future().share();
std::jthread first{[result] { do_stuff(result.get()); }};
std::jthread second{[result] { do_stuff(result.get()); }};
Mutexes
C++ provides six mutex types:
std::mutex: the default choice for most code;std::timed_mutex;std::recursive_mutex;std::recursive_timed_mutex;std::shared_mutex; andstd::shared_timed_mutex.
Lock and unlock mutexes through RAII wrappers:
std::scoped_lock: the default choice, including when acquiring multiple mutexes;std::unique_lock: a movable lock with deferred locking, timed operations, and manual unlock/relock support;std::lock_guard: a minimal single-mutex scope guard; andstd::shared_lock: a shared-ownership guard for shared mutexes.
Waiting for data
Condition variables
std::condition_variable lets a thread sleep until it is notified. The predicate is essential: it handles spurious wakeups and checks the shared state under the mutex.
Cancelling a wait
C++20’s stop-token-aware wait overload is provided by std::condition_variable_any:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
std::condition_variable_any condition;
void wait_for_data(std::stop_token token) {
std::unique_lock lock{mutex};
bool data_ready = condition.wait(lock, token, [] {
return data.has_value();
});
if (!data_ready) {
return; // cancellation won the race
}
Data result = std::move(*data);
lock.unlock();
process_data(result);
}
## Semaphores
A semaphore represents a number of available slots. Acquiring a slot decreases the count; releasing a slot increases it. An acquire attempted when the count is zero either blocks or fails, depending on the operation used.
Semaphore ownership is not tied to a particular thread. One thread may release a slot acquired by another, and a release does not require that the same thread previously acquired it.
Semaphores can be used to construct mutexes, latches, barriers, and many other synchronization mechanisms. The Little Book of Semaphores explores these patterns, though application code is usually better served by the higher-level facility that directly represents its intent.
C++20 provides:
std::counting_semaphore<max_count>for a count between zero and an implementation-supported maximum; andstd::binary_semaphore, an alias forstd::counting_semaphore<1>, with either one free slot or none.
The acquisition operations are:
acquire(), which blocks until a slot is available;try_acquire(), which attempts without blocking;try_acquire_for(duration), which waits up to a duration; andtry_acquire_until(time_point), which waits until a deadline.
release() returns one or more slots.
1
2
3
4
5
6
7
std::counting_semaphore<5> slots{5};
void func() {
slots.acquire();
do_stuff(); // at most five threads can be here
slots.release();
}
The standard semaphore itself is not an RAII guard. If do_stuff() can throw, wrap the acquired permit in an application-specific scope guard so the slot is always released.
Atomics
Atomic variables are the lowest-level synchronization primitive covered here. The primary template is std::atomic<T>.
For the primary template, T must be an eligible, cv-unqualified, bitwise comparable, trivially copyable type with the required copy/move construction and assignment properties.
Atomic compare-and-exchange is defined in terms of the stored value representation rather than an overloaded operator==.
C++ also provides dedicated std::atomic<std::shared_ptr<U>> and std::atomic<std::weak_ptr<U>> specializations.
An atomic operation is indivisible with respect to other atomic accesses to the same object, but std::atomic<T> is not necessarily lock-free. An implementation may use an internal lock.
The types guaranteed to be lock-free are:
std::atomic_flag;std::atomic_signed_lock_free; andstd::atomic_unsigned_lock_free.
On most common platforms, atomic integral and pointer types are also lock-free, but portable code must not assume this. Query a type at compile time with:
1
static_assert(std::atomic<T>::is_always_lock_free);
An individual object can also be queried at runtime with object.is_lock_free().
Atomics are powerful but require precise reasoning about atomic read-modify-write operations, memory ordering, and object lifetimes. Prefer an appropriate higher-level synchronization facility unless atomics are genuinely necessary.
Practical defaults
For new C++20 code, a useful order of preference is:
- Avoid directly managing threads when a parallel algorithm or task abstraction already fits.
- Use
std::jthreadwhen a dedicated thread is appropriate. - Use
std::stop_tokenfor cooperative cancellation. - Use
std::future,std::latch, orstd::barrierwhen the problem matches their one-shot result, countdown, or phased-coordination models. - Use
std::mutexfor most remaining shared-state protection. - Reach for
std::atomiconly in the rarer cases that justify its lower-level complexity.