Language and Runtime Concurrency Models: C++
C++ wraps the operating system's threads in type-safe, exception-safe classes and adds a precise memory model.
Learning Objectives
Use std::thread, std::jthread and stop tokens; mutexes with lock_guard, unique_lock and scoped_lock; condition variables; C++20 semaphores, latches and barriers; atomics and memory orders.
Use std::future, std::promise, std::packaged_task; know std::async's caveats and execution policies.
Separate C++20 coroutine machinery from schedulers; write exception-safe RAII concurrency; use sanitizers and profilers.
Plain-Language Explanation
C++ wraps the operating system's threads in type-safe, exception-safe classes and adds a precise memory model. Its signature idea is RAII: locks, threads and stop requests are objects whose destructors clean up automatically, so the right thing happens even when exceptions fly.
Precise Technical Explanation
Starts immediately; must be joined or detached before destruction (else std::terminate); arguments are copied (use std::ref for references).
std::jthread and stop tokens (C++20) Joins in its destructor and owns a std::stop_source; the callable may take a std::stop_token; std::stop_callback runs code when stop is requested; std::condition_variable_any::wait(lock, stop_token, pred) wakes on stop requests (shown below).
Mutexes, lock guards, unique locks, scoped locks std::mutex, recursive_mutex, timed_mutex, shared_mutex; lock_guard (scope), unique_lock (movable, deferrable, needed by condition variables), scoped_lock (several mutexes, deadlock-avoiding), shared_lock (readers).
std::condition_variable (with unique_lock<mutex>) and condition_variable_any (any lockable, stop tokens); always with predicates.
Semaphores, latches, barriers (C++20 where supported) std::counting_semaphore, binary_semaphore, std::latch, std::barrier (Module 4 example).
Atomics and memory orders std::atomic<T>, atomic_ref (C++20), atomic::wait/notify_one/notify_all (C++20 — futex-like blocking on an atomic), memory orders and fences (Modules 6–7).
Futures, promises, packaged tasks std::promise<T> sets a value/exception that a std::future<T> delivers; std::packaged_task wraps a callable so its result goes into a future; std::shared_future for many readers. There are no continuations (.then) in the standard.
std::async caveats std::async(f) with the default policy may run f *deferred* (lazily, in the waiting thread) or asynchronously — implementation's choice; pass std::launch::async to force a thread. The future returned by std::async blocks in its destructor until the task finishes, so discarding it serialises "async" calls (demonstrated below).
std::execution::seq/par/par_unseq/unseq for standard algorithms (C++17). With GCC's libstdc++, parallel execution needs Intel TBB at build time; without TBB the algorithms fall back to serial execution — demonstrated below on a machine without TBB.
C++20 coroutines: language machinery versus actual scheduler
C++20 supplies co_await/co_yield/co_return and the promise/awaiter protocol only (Module 12). Scheduling, task types and I/O integration come from libraries (Boost.Asio, cppcoro, folly::coro, libunifex, std::execution in C++26).
RAII and exception safety Locks release in destructors; jthread joins in its destructor; std::thread does *not* — an exception between creation and join terminates the program unless guarded. Prefer jthread and scoped locks.
Sanitizers and profiling -fsanitize=thread, -fsanitize=address,undefined; perf, VTune, Tracy; Clang's -Wthread-safety annotations (GUARDED_BY, REQUIRES) for compile-time lock checking.
Key Terms
Joining thread with built-in stop source Cooperative cancellation handle
RAII multi-mutex lock with deadlock avoidance Callable whose result goes into a future
std::async runs the task lazily in get() Requests parallel/vectorised algorithm execution
Diagram or Execution Timeline
playgroundSyntax and API Overview
The specification's atomic example (G++ 11.2):
Each fetch_add is an atomic read-modify-write, so no increment is lost; relaxed ordering is adequate because only the counter's final value matters and join() orders the final read. It would *not* be adequate for publishing associated non-atomic data.
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::atomic<int> counter{0};
std::vector<std::thread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([&counter] {
for (int j = 0; j < 10000; ++j) {
counter.fetch_add(1, std::memory_order_relaxed);
}
});
}
for (auto& worker : workers) worker.join();
std::cout << counter.load() << '\n'; // 40000
}Complete Code Example
Promises, packaged tasks, the std::async destructor trap, stop-aware condition variables, atomic::wait, and execution policies without TBB (G++ 11.2, libstdc++ 11, one real run):
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <execution>
#include <future>
#include <iostream>
#include <mutex>
#include <numeric>
#include <stdexcept>
#include <thread>
#include <vector>
using namespace std::chrono;
int main() {
// 1. promise/future carry a value or an exception across threads
std::promise<int> p;
std::future<int> f = p.get_future();
std::jthread producer([&p] {
try { throw std::runtime_error("sensor offline"); }
catch (...) { p.set_exception(std::current_exception()); }
});
try { f.get(); } catch (const std::exception& e) { std::cout << "future.get rethrew: " << e.what() << '\n'; }
// 2. packaged_task: wrap a callable, run it anywhere, collect the result
std::packaged_task<int(int, int)> task([](int a, int b) { return a * b; });
std::future<int> product = task.get_future();
std::jthread(std::move(task), 6, 7).join();
std::cout << "packaged_task result: " << product.get() << '\n';
// 3. std::async's returned future blocks in its destructor
auto t0 = steady_clock::now();
for (int i = 0; i < 3; ++i) {
std::async(std::launch::async, [
…compiler output:
m17cpp_toolkit.cpp: In function ‘int main()’:
m17cpp_toolkit.cpp:35:83: warning: ignoring return value of ‘std::future<typename std::__invoke_result<typename std::decay<_Tp>::type, typename std::decay<_Args>::type ...>::type> std::async(std::launch, _Fn&&, _Args&& ...) [with _Fn = main()::<lambda()>; _Args = {}; typename std::__invoke_result<typename std::decay<_Tp>::type, typename std::decay<_Args>::type ...>::type = void; typename std::decay<_Tp>::type = std::decay<main()::<lambda()> >::type]’, declared with attribute ‘nodiscard’ [-Wunused-result]
35 | std::async(std::launch::async, [] { std::this_thread::sleep_for(100ms); }); // temporary future destroyed here
| ^
In file included from m17cpp_toolkit.cpp:6:
/usr/include/c++/11/future:1757:5: note: declared here
1757 | async(launch __policy, _Fn&& __fn, _Args&&... __args)
| ^~~~~
program output:
future.get rethrew: sensor offline
packaged_task result: 42
3 x 100 ms std::async: futures discarded ~300 ms (serialised), kept ~100 ms (concurrent)
waiter woke: stop requested, exiting cleanly
atomic::wait
…Line-by-Line Explanation
set_exception transports the exception; get() rethrows it in the waiting thread.
packaged_task is moved into a jthread with its arguments; the future receives 42.
Each discarded std::async future's destructor waits for its task — three calls take ~300 ms; keeping the futures alive lets the three tasks overlap (~100 ms). GCC even warns about it: std::async is declared [[nodiscard]] in libstdc++, which is why the compiler output above the program output flags that line.
condition_variable_any::wait(lock, stop_token, pred) returns false when a stop is requested before the predicate became true.
atomic::wait(0) blocks while the value is 0; notify_one wakes it after the store.
std::reduce(std::execution::par, …) compiles and runs, but with no TBB backend libstdc++ executes it serially — the times are similar.
Output
Shown above; timings rounded from one real run.
Interleaving or State Trace
std::async trap: iteration 1 starts task A; the temporary future is destroyed at the end of the full-expression → waits ~100 ms for A; then iteration 2 starts B and waits again — effectively sequential.
Correctness and Safety Analysis
All cross-thread results travel through synchronizing objects (future/promise, joins, atomics with seq_cst defaults, the mutex-protected work_ready). jthread destructors guarantee no thread outlives the objects it captures by reference.
Common Mistakes and Misconceptions
Discarding std::async's future; relying on the default launch policy.
Destroying a joinable std::thread (terminate).
Capturing locals by reference in a detached thread.
Expecting execution policies to parallelise everywhere.
Treating C++20 coroutines as a ready-to-use async runtime.
Failure Modes and Debugging Steps
std::terminate at thread destruction → missing join; future_error: broken promise → a promise destroyed without setting a value; hangs in future::get → value never set (error path); TSan for races; -Wthread-safety (Clang) for lock discipline.
Performance and Scalability Implications
std::thread maps 1:1 to OS threads — use pools (libraries) for many small tasks; std::async spawns threads per call in libstdc++; atomics and atomic::wait avoid mutexes for simple signalling; parallel algorithms need a backend (TBB, MSVC's built-in pool, Clang/libc++ backends).
Security Considerations
Use-after-free across threads (detached threads, dangling references in lambdas) is the main memory-safety risk; prefer jthread, value captures and shared ownership (shared_ptr) for data that crosses threads.
Platform and Runtime Caveats
C++20 thread facilities need GCC 11+/Clang 16+ (libc++ caught up later)/MSVC 19.28+; GCC 10–11 need -fcoroutines for coroutines.
MSVC's parallel algorithms use the Windows thread pool without TBB; libstdc++ requires TBB (-ltbb) for real parallelism.
Real-World Applications
Game engines (job systems over std::thread), trading systems (atomics, lock-free queues), browsers (Chromium's task scheduler), databases (ScyllaDB's Seastar), scientific codes (parallel algorithms, OpenMP).
Best Practices
jthread + stop_token for threads; scoped_lock for locks.
Keep std::async futures; specify std::launch::async.
Use library executors for many tasks; verify execution-policy backends.
Annotate lock requirements; run TSan.
Exercises
E1 · Beginner. Why does std::async(std::launch::async, f); on its own line not run concurrently with the next line?
Answer: The returned future is a temporary destroyed at the end of the statement, and that destructor blocks until f finishes.
E2 · Intermediate. Write a jthread worker that waits on a condition variable but exits promptly when stop is requested.
Answer: Use std::condition_variable_any cv; and in the worker cv.wait(lock, st, [&]{ return ready; }); if it returns false, stop was requested — clean up and return. (std::condition_variable cannot be woken by a stop token directly.)
E3 · Advanced challenge. Implement a one-shot event with std::atomic<bool> using C++20 wait/notify_all.
Answer: void set() { flag.store(true, std::memory_order_release); flag.notify_all(); } void wait() { flag.wait(false, std::memory_order_acquire); } — wait(false) blocks while the value equals false and re-checks after wake-ups; release/acquire publish prior writes.
Quiz
? What happens when a std::future returned by std::async is destroyed while its task runs?
+ The destructor waits for the task to finish. :: Unique to futures from std::async.
- The task is cancelled. :: There is no cancellation.
- The program terminates. :: That is std::thread's rule.
- Nothing; the task continues detached. :: It blocks.
? Which C++ type joins automatically in its destructor?
+ std::jthread :: C++20.
- std::thread :: It calls std::terminate if still joinable.
- std::async :: That is a function.
- std::packaged_task :: It is a callable wrapper.
? Which are true? (select all that apply)
+ std::scoped_lock can lock several mutexes without deadlocking against itself. :: Deadlock-avoidance algorithm.
- C++20 provides a coroutine scheduler. :: Only the machinery.
+ std::atomic<T>::wait blocks until the value changes from the given one. :: C++20.
- std::execution::par always runs in parallel. :: Implementation and backend dependent.Summary
C++ offers RAII threads (jthread with stop tokens), a family of mutexes and lock types, condition variables, C++20 semaphores/latches/barriers, atomics with wait/notify, futures/promises/packaged tasks, execution policies and coroutine machinery. Know the traps — std::async's blocking future, joinable-thread termination, backend-dependent parallel algorithms — and use sanitizers.
Further Reading
Anthony Williams, *C++ Concurrency in Action*, 2nd ed.
cppreference: Thread support library, Atomic operations library, Execution policies.
Herb Sutter, "Concurrency" chapters in *Exceptional C++* and GotW articles.
Lewis Baker, "Asymmetric Transfer" (coroutines blog).