Skip to content
C++
Library
since C++20
Basic

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++20

A 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):

  1. Automatic join β€” the destructor calls request_stop() then join(), so a scope exit (normal or via exception) never leaves a dangling thread or triggers std::terminate.
  2. Cooperative cancellation β€” every jthread owns a std::stop_source. If the callable accepts std::stop_token as its first argument, the runtime passes it automatically. Workers poll stop_requested() or register stop_callback to 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:

TypeRole
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

cpp
// 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;
};
cpp
// 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

cpp
#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.

cpp
#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:

cpp
#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:

cpp
#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 jthread over thread for 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 with sleep_for in a loop.
  • Capture stop tokens by value in lambdas. stop_token is a lightweight shared-ownership handle; copying is cheap and avoids dangling references.
  • Use a shared stop_source for worker pools β€” do not rely on the jthread's internal source when you need coordinated shutdown.

Common Pitfalls

Forgetting the stop_token parameter prevents injection.

cpp
// 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.

cpp
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 guarded

Calling 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.

cpp
#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.

cpp
#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.

cpp
#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.

cpp
#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.

cpp
// ── 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:

  1. Replace std::atomic<bool> stop_flag with the std::stop_token parameter.
  2. Replace stop_flag.store(true) at shutdown with jthread::request_stop() (or simply let the destructor do it).
  3. Remove the explicit join guard β€” jthread's destructor is the guard.
  4. If a condition_variable timed-wait was used to avoid spin-polling, replace it with condition_variable_any::wait(lock, st, pred).
  5. If multiple threads shared one flag, create a standalone std::stop_source and distribute tokens.
cpp
// 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.

cpp
#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.

cpp
#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.

Featurestd::jthread (C++20)Coroutines (C++20)std::execution / P2300 (C++26)
RAII lifetimeYes β€” auto-join on scope exitN/A β€” coroutine frame is heap-allocatedYes β€” senders are composable values
Cooperative cancellationstop_token / stop_callbackco_await std::stop_token (library-dependent)stop_token threaded through sender chains
Structured concurrencyManual (scope + RAII pool)Manual or via library (async_scope)First-class (when_all, let_value)
Scheduler controlOS scheduler; no user controlSingle-thread by default; needs executorExplicit scheduler attachment (on(sched, work))
Fan-outVector of jthreadasync_scope / coroutine launcherswhen_all / bulk
Heterogeneous execution (GPU, I/O)NoWith custom awaitablesYes β€” scheduler abstraction covers CPU, GPU, I/O
OverheadOne OS thread per taskHeap frame + suspension overheadSub-microsecond on inline scheduler
cpp
// 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