std::jthread and Cooperative Cancellation (C++20)
"C++20 std::jthread: auto-joining thread with cooperative cancellation via stop_token, stop_source, and stop_callback."
std::jthreadsince C++20A thread type that automatically joins on destruction and provides built-in cooperative cancellation through an associated stop_source/stop_token pair.
Overview
std::jthread (C++20, <thread>) addresses two longstanding defects of std::thread (C++11):
- Automatic join β the destructor calls
request_stop()thenjoin(), so a scope exit (normal or via exception) never leaves a dangling thread or triggersstd::terminate. - Cooperative cancellation β every
jthreadowns astd::stop_source. If the callable acceptsstd::stop_tokenas its first argument, the runtime passes it automatically. Workers pollstop_requested()or registerstop_callbackto react promptly.
The cancellation mechanism is cooperative, not preemptive. The requesting side signals intent; the worker side decides when and how to stop. This avoids the data-corruption hazards of forceful thread termination.
The stop_token family
Three types form the cancellation protocol:
| Type | Role |
|---|---|
std::stop_source (C++20) | Owns the shared stop state; issues the stop request |
std::stop_token (C++20) | Non-owning, read-only handle β passed to workers |
std::stop_callback<F> (C++20) | RAII callback registered on a token; fires on stop |
Syntax
// jthread member functions (C++20)
class jthread {
public:
// Constructors
jthread() noexcept; // empty, no-op destructor
template<class F, class... Args>
explicit jthread(F&& f, Args&&... args); // starts thread immediately
// if F's first param is stop_token,
// it is injected automatically
jthread(jthread&&) noexcept; // moveable, not copyable
~jthread(); // request_stop() + join() if joinable
// Stop support
stop_source get_stop_source() noexcept; // returns the internal stop_source
stop_token get_stop_token() const noexcept;
bool request_stop() noexcept; // signals the internal stop_source
// Inherited from thread (C++11 interface)
void join();
void detach();
bool joinable() const noexcept;
id get_id() const noexcept;
native_handle_type native_handle();
static unsigned hardware_concurrency() noexcept;
};// stop_token / stop_source (C++20)
std::stop_source src;
std::stop_token tok = src.get_token();
src.request_stop(); // β bool: true if this call caused the transition
src.stop_requested(); // β bool
src.stop_possible(); // β bool: false only for default-constructed source
tok.stop_requested(); // β bool
tok.stop_possible(); // β bool: false for default-constructed token (no source)
// stop_callback<F> (C++20) β CTAD deduces F
std::stop_callback cb{tok, callable};
// Fires callable when stop is requested, or immediately if already stopped.
// Destructor deregisters; thread-safe with concurrent request_stop().Examples
Basic auto-join and cancellation
#include <thread>
#include <chrono>
#include <print>
void producer(std::stop_token st) {
int n = 0;
while (!st.stop_requested()) {
std::println("produced {}", n++);
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
std::println("producer: clean exit at {}", n);
}
int main() {
std::jthread t(producer); // C++20: stop_token injected automatically
std::this_thread::sleep_for(std::chrono::milliseconds(350));
t.request_stop(); // signal intent; producer exits its loop
// t.join() is implicit β no explicit call needed
}Blocking wait with stop_token
std::condition_variable_any (C++11) gained C++20 overloads that accept a stop_token. The wait unblocks on either the predicate or a stop request.
#include <thread>
#include <mutex>
#include <queue>
#include <condition_variable>
#include <functional>
struct WorkQueue {
std::mutex mtx;
std::condition_variable_any cv;
std::queue<std::function<void()>> tasks;
};
void drain_queue(std::stop_token st, WorkQueue& wq) {
std::unique_lock lock(wq.mtx);
while (true) {
// C++20 overload: wakes on task OR stop request
bool has_task = wq.cv.wait(lock, st, [&]{ return !wq.tasks.empty(); });
if (!has_task) return; // stop requested while queue was empty
auto task = std::move(wq.tasks.front());
wq.tasks.pop();
lock.unlock();
task();
lock.lock();
}
}Without the stop_token overload you would need a separate condition to wake the thread, or poll with a timeout β both less clean.
Shared stop_source across multiple workers
jthread's internal stop_source is per-thread. To stop a group atomically, create a stop_source explicitly and distribute tokens manually:
#include <thread>
#include <vector>
void worker(std::stop_token st, int id) {
while (!st.stop_requested()) {
process_shard(id);
}
}
int main() {
std::stop_source group_stop;
std::vector<std::jthread> pool;
for (int i = 0; i < 8; ++i)
pool.emplace_back(worker, group_stop.get_token(), i);
run_until_done();
group_stop.request_stop(); // all 8 workers see stop_requested() simultaneously
// pool destruction joins all threads
}Note: pool.emplace_back(worker, group_stop.get_token(), i) does not inject the jthread's own stop token because the callable's first argument is already provided. Each jthread still has its own internal stop source, but the workers only check group_stop's token.
stop_callback for I/O interruption
Polling stop_requested() does not help if a thread is blocked in a system call. stop_callback solves this by running arbitrary code when stop fires β typically closing a file descriptor or posting to a semaphore:
#include <thread>
#include <sys/socket.h>
#include <unistd.h>
void network_reader(std::stop_token st, int listen_fd) {
// Register shutdown action before entering blocking call
std::stop_callback close_on_stop{st, [=]{ ::shutdown(listen_fd, SHUT_RD); }};
while (!st.stop_requested()) {
sockaddr_storage addr{};
socklen_t len = sizeof(addr);
int client = ::accept(listen_fd,
reinterpret_cast<sockaddr*>(&addr), &len);
if (client < 0) break; // accept() returns -1 after shutdown
handle_client(client);
}
}stop_callback lifetime rules (C++20):
- If stop has already been requested at construction time, the callback is invoked inline in the constructor, on the constructing thread.
- Otherwise it is invoked by whichever thread calls
request_stop(). - The destructor is thread-safe with a concurrent
request_stop(): it either returns after the callback completes, or ensures the callback will not be called again.
Best Practices
- Prefer
jthreadoverthreadfor all new code. The auto-join guarantee eliminates an entire class of resource-leak bugs with no runtime overhead. - Check
stop_requested()at natural preemption points β top of a loop, before a sleep, before a blocking call β rather than deeply inside computation. - Use
condition_variable_any+ stop token instead of timed waits.cv.wait_for(lock, st, timeout, pred)is strictly superior to polling withsleep_forin a loop. - Capture stop tokens by value in lambdas.
stop_tokenis a lightweight shared-ownership handle; copying is cheap and avoids dangling references. - Use a shared
stop_sourcefor worker pools β do not rely on thejthread's internal source when you need coordinated shutdown.
Common Pitfalls
Forgetting the stop_token parameter prevents injection.
// Wrong: callable takes no stop_token β jthread's token is never passed
std::jthread t([](int x) { while (true) { work(x); } }, 42);
t.request_stop(); // signals the internal source, but nobody checks it
// thread runs forever until the destructor forcibly joins after request_stop()stop_callback captures a dangling reference.
void bad(std::stop_token st) {
std::string buf;
std::stop_callback cb{st, [&buf]{ buf.clear(); }}; // &buf dangles if 'bad' returns
blocking_io();
// if stop fires after 'bad' returns, buf is destroyed β UB
}
// Fix: capture by value, or tie the callback's lifetime to the scope being guardedCalling detach() discards the auto-join guarantee. A detached jthread will still have its internal stop requested at destruction, but the thread itself runs on unchecked. Avoid detach() unless you have another lifetime-management strategy.
stop_callback is not re-entrant. If the callback itself calls request_stop() on the same source, the second call is a no-op (stop is already set). This is safe, but it means the callback fires exactly once.
stop_token and Cooperative Cancellation
Claim. Passing std::stop_token as the first parameter of the callable is the idiomatic way to build cancellable workers β the runtime injects it automatically and jthread's destructor triggers the signal.
#include <thread>
#include <condition_variable>
#include <mutex>
#include <queue>
#include <functional>
#include <print>
// C++20
void worker(std::stop_token st, int id) {
while (!st.stop_requested()) { // poll at the top of each iteration
do_unit_of_work(id);
}
std::println("worker {}: stop received, exiting cleanly", id);
}
int main() {
std::jthread t(worker, 1); // stop_token injected automatically
std::this_thread::sleep_for(std::chrono::seconds(1));
t.request_stop(); // signal from the owner
// ~jthread calls request_stop() + join() β explicit call above is optional
}When a worker is blocked waiting on a std::condition_variable_any, simple polling is insufficient. std::stop_callback bridges the gap by waking the wait at stop time.
#include <thread>
#include <condition_variable>
#include <mutex>
#include <queue>
#include <functional>
// C++20
void draining_worker(std::stop_token st,
std::mutex& mtx,
std::condition_variable_any& cv,
std::queue<std::function<void()>>& q) {
std::unique_lock lock(mtx);
while (true) {
// C++20 overload: wakes on predicate OR stop request
bool got_work = cv.wait(lock, st, [&]{ return !q.empty(); });
if (!got_work) break; // stop_requested() while queue empty
auto task = std::move(q.front());
q.pop();
lock.unlock();
task();
lock.lock();
}
}The stop_callback alternative is useful when the wait primitive does not natively accept a stop_token β register a callback that calls cv.notify_all() and the blocked thread wakes, rechecks its predicate, and exits.
stop_source and stop_callback in Depth
Claim. Creating a std::stop_source independently of any jthread lets a single stop signal coordinate an arbitrary number of threads, even those that are not jthread instances.
#include <thread>
#include <vector>
#include <print>
// C++20
void sensor_reader(std::stop_token st, int channel) {
while (!st.stop_requested()) {
read_sensor(channel);
}
std::println("channel {} reader stopped", channel);
}
int main() {
std::stop_source group; // independent stop_source
std::vector<std::jthread> readers;
for (int ch = 0; ch < 4; ++ch)
readers.emplace_back(sensor_reader, group.get_token(), ch);
// Each jthread receives group's token, NOT its own internal token.
// All four threads observe the same stop state.
acquire_data_for(std::chrono::seconds(5));
group.request_stop(); // all four readers stop atomically
// readers' destructors join the threads
}std::stop_callback has strict RAII semantics: the callback deregisters when the stop_callback object is destroyed, so its lifetime determines the window during which the callback can fire.
#include <thread>
// C++20
void safe_callback_lifetime(std::stop_token st) {
{
// Callback is live only inside this block.
std::stop_callback cb{st, []{ notify_supervisor(); }};
do_critical_section();
// cb destructor: if request_stop() races here, destructor blocks
// until the callback completes, then deregisters.
}
// Past this point, notify_supervisor() will never be called.
do_remaining_work();
}Calling request_stop() is safe from any thread and any number of times β only the first call returns true and triggers registered callbacks; subsequent calls are no-ops. Callbacks execute synchronously on the thread that first calls request_stop(), so keep them short or delegate heavy work to another thread.
jthread vs thread: Migration Guide
Claim. Migrating from std::thread to std::jthread eliminates both the manual join guard and the atomic<bool> stop flag, replacing them with language-supported primitives that compose correctly with exceptions.
// ββ Before (C++11 std::thread) ββββββββββββββββββββββββββββββββββββββββββββββ
#include <thread>
#include <atomic>
#include <chrono>
struct LegacyWorker {
std::atomic<bool> stop_flag{false};
std::thread t;
LegacyWorker() : t([this]{
while (!stop_flag.load(std::memory_order_relaxed)) {
do_work();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}) {}
~LegacyWorker() {
stop_flag.store(true, std::memory_order_relaxed);
if (t.joinable()) t.join(); // must not forget; UB if missed
}
};
// ββ After (C++20 std::jthread) ββββββββββββββββββββββββββββββββββββββββββββββ
#include <thread>
#include <chrono>
struct ModernWorker {
std::jthread t{[](std::stop_token st){ // stop_token injected automatically
while (!st.stop_requested()) {
do_work();
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}};
// Destructor: request_stop() + join() β no manual bookkeeping
};Migration checklist:
- Replace
std::atomic<bool> stop_flagwith thestd::stop_tokenparameter. - Replace
stop_flag.store(true)at shutdown withjthread::request_stop()(or simply let the destructor do it). - Remove the explicit join guard β
jthread's destructor is the guard. - If a
condition_variabletimed-wait was used to avoid spin-polling, replace it withcondition_variable_any::wait(lock, st, pred). - If multiple threads shared one flag, create a standalone
std::stop_sourceand distribute tokens.
// C++20 β confirming destructor order: request_stop is called before join
{
std::jthread t([](std::stop_token st){
while (!st.stop_requested()) do_work();
});
// end of scope β ~jthread: first request_stop(), then join()
// thread always exits before the scope completes
}Producer-Consumer with jthread
Claim. std::condition_variable_any's stop-token overloads let both producer and consumer exit their blocking waits cleanly when request_stop() is called, with no extra flags or timeout hacks.
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <optional>
#include <print>
// C++20
template<typename T, std::size_t Capacity>
struct BoundedChannel {
std::mutex mtx;
std::condition_variable_any not_full;
std::condition_variable_any not_empty;
std::queue<T> buf;
// Returns false if the channel was stopped before space became available.
bool push(std::stop_token st, T value) {
std::unique_lock lock(mtx);
bool ok = not_full.wait(lock, st,
[&]{ return buf.size() < Capacity; });
if (!ok) return false; // stop requested
buf.push(std::move(value));
not_empty.notify_one();
return true;
}
// Returns nullopt if stopped and queue drained.
std::optional<T> pop(std::stop_token st) {
std::unique_lock lock(mtx);
bool ok = not_empty.wait(lock, st,
[&]{ return !buf.empty(); });
if (!ok) return std::nullopt;
T val = std::move(buf.front());
buf.pop();
not_full.notify_one();
return val;
}
};
int main() {
BoundedChannel<int, 8> ch;
std::stop_source done;
std::jthread producer(
[&ch](std::stop_token st){
for (int i = 0; ; ++i) {
if (!ch.push(st, i)) break;
}
std::println("producer done");
}, done.get_token());
std::jthread consumer(
[&ch](std::stop_token st){
while (auto v = ch.pop(st))
std::println("consumed {}", *v);
std::println("consumer done");
}, done.get_token());
std::this_thread::sleep_for(std::chrono::milliseconds(200));
done.request_stop(); // wakes both via stop_callback in wait
// jthread destructors join β no explicit join needed
}Both not_full.wait and not_empty.wait internally register a stop_callback that calls notify_all() on the respective condition variable, so neither thread hangs waiting for the other when shutdown is requested.
Thread Pool Using jthread
Claim. A minimal thread pool built on std::jthread gets cancellation and clean shutdown for free β no separate shutdown flag or join loop required.
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <functional>
#include <vector>
#include <stdexcept>
// C++20
class ThreadPool {
public:
explicit ThreadPool(std::size_t n) {
workers_.reserve(n);
for (std::size_t i = 0; i < n; ++i)
workers_.emplace_back([this](std::stop_token st){ run(st); });
}
// Destructor: each jthread requests stop then joins β workers drain and exit.
~ThreadPool() = default;
void submit(std::function<void()> task) {
{
std::scoped_lock lock(mtx_);
if (stopped_) throw std::runtime_error("pool is shutting down");
queue_.push(std::move(task));
}
cv_.notify_one();
}
// Graceful shutdown: stop accepting new work and wait for queue to drain.
void shutdown() {
{
std::scoped_lock lock(mtx_);
stopped_ = true;
}
cv_.notify_all();
for (auto& t : workers_) t.request_stop();
// jthread destructors will join automatically
}
private:
void run(std::stop_token st) {
while (true) {
std::function<void()> task;
{
std::unique_lock lock(mtx_);
// C++20: wakes on work available OR stop request
bool has_work = cv_.wait(lock, st,
[&]{ return !queue_.empty(); });
if (!has_work) return; // stopped with empty queue β exit
task = std::move(queue_.front());
queue_.pop();
}
task(); // execute outside the lock
}
}
std::mutex mtx_;
std::condition_variable_any cv_;
std::queue<std::function<void()>> queue_;
std::vector<std::jthread> workers_;
bool stopped_{false};
};Each worker waits on cv_.wait(lock, st, pred) β the C++20 overload that registers a stop_callback internally. When request_stop() fires, every sleeping worker wakes, finds the queue empty, and returns. Tasks already in the queue are not executed after stop is requested; adjust the predicate to return !queue_.empty() || stopped_ and drain before exiting if needed.
Comparing jthread, coroutines, and std::execution
Claim. These three concurrency models are complementary rather than competing: choose based on whether your bottleneck is thread lifecycle management, single-thread async latency, or composable task-graph throughput.
| Feature | std::jthread (C++20) | Coroutines (C++20) | std::execution / P2300 (C++26) |
|---|---|---|---|
| RAII lifetime | Yes β auto-join on scope exit | N/A β coroutine frame is heap-allocated | Yes β senders are composable values |
| Cooperative cancellation | stop_token / stop_callback | co_await std::stop_token (library-dependent) | stop_token threaded through sender chains |
| Structured concurrency | Manual (scope + RAII pool) | Manual or via library (async_scope) | First-class (when_all, let_value) |
| Scheduler control | OS scheduler; no user control | Single-thread by default; needs executor | Explicit scheduler attachment (on(sched, work)) |
| Fan-out | Vector of jthread | async_scope / coroutine launchers | when_all / bulk |
| Heterogeneous execution (GPU, I/O) | No | With custom awaitables | Yes β scheduler abstraction covers CPU, GPU, I/O |
| Overhead | One OS thread per task | Heap frame + suspension overhead | Sub-microsecond on inline scheduler |
// C++20 β jthread: best for long-lived, blocking, thread-per-task workloads
std::jthread io_thread([](std::stop_token st){
while (!st.stop_requested()) poll_network(st);
});
// C++20 β coroutines: best for single-thread async I/O (e.g. HTTP server)
// co_await async_read(socket); // suspends without blocking the OS thread
// C++26 β std::execution: best for composable CPU task graphs
// auto work = ex::when_all(compute_a(sched), compute_b(sched))
// | ex::then([](auto a, auto b){ return combine(a, b); });
// ex::sync_wait(std::move(work));Use jthread when you need a real OS thread with blocking I/O, an existing thread-based API, or simple background work with clean shutdown. Prefer coroutines for high-connection-count async I/O on a single thread. Reach for std::execution when you need composable, scheduler-agnostic task graphs that can retarget from CPU thread pools to GPU command queues without changing the logic.
See Also
std::threadβ C++11 baseline; usejthreadin new codestd::condition_variable_anyβ C++11 type; C++20 stop-token overloadsstd::future/std::asyncβ task-based alternative without manual thread managementstd::latch/std::barrierβ C++20 one-shot and reusable synchronization points