Article by Ayman Alheraki on April 21 2026 02:24 PM
C++20 represents a watershed moment for multithreading in the language. While C++11 gave us a solid foundation with std::thread, mutexes, and condition variables, C++20 has addressed many of the pain points that made concurrent programming error-prone and verbose. The headline feature—std::jthread—brings automatic resource management and standardized thread cancellation to the table. But the story doesn't end there: new synchronization primitives (std::latch, std::barrier, std::counting_semaphore), atomic wait/notify operations, and synchronized output streams collectively transform how we write robust, maintainable concurrent code.
To appreciate what C++20 brings, we must first understand the shortcomings of its predecessor. std::thread follows the RAII (Resource Acquisition Is Initialization) pattern incompletely: it manages a native thread handle, but its destructor does not wait for the thread to finish. Instead, if a std::thread object is destroyed while still joinable, it calls std::terminate()—an abrupt and dangerous outcome.
This design forces developers to manually call join() or detach() before the object goes out of scope. In complex control flows—especially those with exceptions—it's remarkably easy to forget, leading to resource leaks or crashes. Moreover, std::thread provides no built-in mechanism for gracefully stopping a running thread; developers must roll their own solution using atomic flags, which often proves fragile and incomplete.
std::jthread (joining thread) is the direct successor to std::thread, designed to eliminate these exact pain points. The 'j' stands for "joining"—and that's its most fundamental improvement.
Automatic Joining. When a std::jthread object goes out of scope, its destructor automatically calls join() if the thread is still joinable. This simple change eliminates an entire class of bugs related to forgotten joins and makes exception safety trivial:
{ std::jthread t([] { // some long-running work std::this_thread::sleep_for(5s); }); // No need to call t.join() — it happens automatically} // Thread is joined here, safe and sound
Built-in Cooperative Cancellation. Every std::jthread internally manages a std::stop_source, which maintains a shared stop state. If the function passed to the jthread constructor accepts a std::stop_token as its first parameter, that token is automatically provided and bound to the internal stop source.
This integration creates a standardized, thread-safe way to request that a thread stop—something that previously required custom, error-prone implementations:
void worker(std::stop_token stoken) { while (!stoken.stop_requested()) { // Do work in manageable chunks process_next_item(); } // Clean up and exit gracefully}
int main() { std::jthread t(worker); // Later, request the thread to stop t.request_stop(); // t's destructor will automatically join}
How It Works Under the Hood. The magic happens through the interplay of three components introduced in C++20:
| Component | Role | Key Methods |
|---|---|---|
std::stop_source | Issues stop requests | request_stop(), get_token(), stop_requested() |
std::stop_token | Queries stop state | stop_requested(), stop_possible() |
std::stop_callback | Registers cleanup actions | Constructor with token + callable |
A std::stop_source and its associated std::stop_token share a reference-counted stop state. Calling request_stop() on the source atomically sets a flag that all associated tokens can observe. This mechanism is thread-safe by design—multiple threads can check the same token without additional synchronization.
The stop_callback adds another dimension: it allows you to register a function that will be invoked exactly once when a stop is requested. This is invaluable for releasing non-RAII resources or notifying other components:
void worker_with_cleanup(std::stop_token stoken) { // This callback runs automatically when stop is requested std::stop_callback cleanup(stoken, [] { std::cout << "Cleaning up external resources...\n"; close_sockets(); flush_buffers(); }); while (!stoken.stop_requested()) { // Work loop }}
Important Caveat. Cooperative cancellation requires cooperation. The thread function must explicitly check stop_requested() at appropriate intervals. If you never check the token, the thread won't magically stop—the jthread destructor will simply block on join() forever.
While std::jthread steals the spotlight, C++20 introduces a suite of synchronization primitives that address common concurrency patterns more elegantly than mutexes and condition variables alone.
A std::latch is a downward counter that blocks waiting threads until it reaches zero. Once zero, it stays zero—it's a single-use synchronization point.
This is perfect for scenarios where a group of worker threads must complete initialization before the main thread proceeds, or where the main thread must signal multiple workers to begin simultaneously:
std::latch start_gate(1); // Wait for 1 count_downstd::latch done_latch(3); // Wait for 3 workers
void worker(int id) { start_gate.wait(); // All workers block here // ... do work ... done_latch.count_down(); // Signal completion}
int main() { std::vector<std::jthread> workers; for (int i = 0; i < 3; ++i) workers.emplace_back(worker, i); // All workers are now waiting at start_gate start_gate.count_down(); // Release all workers simultaneously done_latch.wait(); // Wait for all workers to finish std::cout << "All workers completed\n";}
While a latch is single-use, a std::barrier can be reused repeatedly. It's designed for iterative algorithms where threads must synchronize at the end of each phase before beginning the next.
Barriers shine in scientific computing, parallel rendering, or any problem that can be decomposed into parallel phases with synchronization points between them. A key feature is the optional completion function—a callable that executes exactly once per phase when all threads have arrived:
std::barrier sync_point(4, []() noexcept { std::cout << "Phase complete, advancing...\n";});
void phase_worker(int id) { for (int phase = 0; phase < 5; ++phase) { // Work for this phase process_phase(phase, id); sync_point.arrive_and_wait(); // Synchronize }}
Semaphores—a classic concurrency primitive dating back to Dijkstra—finally arrive in the C++ standard library. They're more lightweight than a mutex-plus-condition-variable combo for many common patterns.
std::counting_semaphore<N> allows up to N concurrent accesses to a resource (think: connection pools, bounded buffers).
std::binary_semaphore is a specialization with a maximum count of 1—a lighter alternative to std::mutex in some scenarios.
std::counting_semaphore<10> connection_pool(10); // Max 10 concurrent connections
void handle_request() { connection_pool.acquire(); // Wait for available connection // ... use connection ... connection_pool.release(); // Return connection to pool}
C++20 adds wait(), notify_one(), and notify_all() member functions to std::atomic<T> and std::atomic_flag. These enable efficient blocking until an atomic value changes, without the overhead of a separate condition variable.
Under the hood, implementations typically use platform-specific mechanisms like Linux's futex, which can park waiting threads in the kernel, consuming no CPU until woken. This is far more efficient than a spin-wait loop:
std::atomic<bool> data_ready(false);
// Producer threadvoid producer() { prepare_data(); data_ready.store(true); data_ready.notify_one(); // Wake one waiting consumer}
// Consumer threadvoid consumer() { data_ready.wait(false); // Blocks until data_ready becomes true process_data();}
If you've ever debugged a multithreaded program, you've encountered the chaos of interleaved std::cout output. C++20's std::osyncstream solves this by buffering output and writing it atomically when destroyed:
void safe_print(int id) { std::osyncstream(std::cout) << "Thread " << id << " completed task successfully\n";}// Output lines are guaranteed not to interleave
The buffer accumulates all output operations and flushes them as a single, indivisible unit to the underlying stream.
Let's combine several C++20 features in a realistic scenario: a parallel task processor that can be cleanly shut down.
class ParallelProcessor {public: ParallelProcessor(int num_workers) : done_latch(num_workers) , phase_barrier(num_workers, [this] { on_phase_complete(); }) { for (int i = 0; i < num_workers; ++i) { workers.emplace_back([this, i](std::stop_token stoken) { worker_loop(stoken, i); }); } }
void shutdown() { // Request all workers to stop for (auto& w : workers) w.request_stop(); // jthread destructors automatically join workers.clear(); std::osyncstream(std::cout) << "All workers shut down.\n"; }
// RAII ensures workers are joined ~ParallelProcessor() { shutdown(); }
private: void worker_loop(std::stop_token stoken, int id) { // Register cleanup callback std::stop_callback cleanup(stoken, [id] { std::osyncstream(std::cout) << "Worker " << id << " cleaning up...\n"; });
// Signal that this worker has started done_latch.arrive_and_wait();
int iteration = 0; while (!stoken.stop_requested()) { // Do work for this iteration { std::osyncstream(std::cout) << "Worker " << id << " iteration " << iteration << "\n"; } std::this_thread::sleep_for(std::chrono::milliseconds(100));
// Synchronize with other workers at phase boundary phase_barrier.arrive_and_wait(); ++iteration; } }
void on_phase_complete() { std::osyncstream(std::cout) << "--- Phase complete ---\n"; }
std::vector<std::jthread> workers; std::latch done_latch; std::barrier phase_barrier;};
int main() { ParallelProcessor processor(3); std::this_thread::sleep_for(std::chrono::seconds(1)); // processor automatically shuts down when main returns}
This example demonstrates:
std::jthread with automatic joining and built-in stop tokens
std::latch for initial synchronization of all workers
std::barrier for phase synchronization with a completion callback
std::stop_callback for cleanup on shutdown
std::osyncstream for clean output
1. Always Check the Stop Token. The cooperative cancellation mechanism only works if your thread function actually checks stop_requested(). Place checks at natural boundaries—after completing a unit of work, before entering a potentially long operation, or inside loops.
2. Interruptible Waiting. std::condition_variable_any provides overloads of wait() and wait_for() that accept a std::stop_token. This allows a thread blocked on a condition variable to wake up and exit when a stop is requested, rather than waiting indefinitely.
3. Stop Source Lifecycle. If you're using std::stop_source independently of std::jthread, ensure the source outlives all threads that hold tokens referencing it. A destroyed stop source invalidates its associated tokens.
4. Thread Pools. std::jthread simplifies thread pool implementation considerably. Each worker thread can be a std::jthread that accepts a shared std::stop_token. Shutting down the pool becomes a matter of calling request_stop() on the shared source and letting the jthread destructors handle the joins.
5. Performance. The new synchronization primitives are designed to be lightweight. std::latch and std::barrier typically use atomic operations rather than heavier mutexes. Atomic wait/notify can leverage platform-specific optimizations like futex. For most applications, these primitives are more efficient than hand-rolled alternatives.
C++20 transforms multithreading from a necessary evil into a well-supported, safe, and expressive part of the language. std::jthread eliminates the most common footguns associated with thread lifecycle management. The cooperative cancellation framework provides a standardized, composable way to gracefully stop asynchronous operations. And the expanded toolbox of synchronization primitives lets you express complex coordination patterns with clarity and confidence.
For new C++ code, std::jthread should be your default choice for launching threads. The small syntactic change from std::thread belies a profound improvement in safety and expressiveness. Combined with the other C++20 concurrency features, you can write multithreaded code that is not only correct but also readable, maintainable, and performant.