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

playground

Syntax 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).