Threads in Modern C++
std::thread and std::jthread, cooperative cancellation with stop_token, and the lifetime and ownership rules that govern joining versus detaching.
What we’ll cover
- Compare
std::threadand C++20’sstd::jthread, includingjthread’s automatic request-and-join on destruction. - Introduce
stop_tokenandstop_sourceas the standard’s cooperative-cancellation mechanism, and what it refuses to do. - Establish the ownership and lifetime rules a thread object imposes on the code that creates it.
- Contrast joining and detaching, and why detaching is rarely the right default in service code.
std::thread and std::jthread
std::thread wraps an operating-system thread in a move-only object: construct
it with a callable and arguments, and the thread starts running immediately,
before the constructor even returns. There is no separate “start” step and no
way to create a std::thread in a suspended state. That immediacy is easy to
forget, because it means a thread object can begin touching shared state while
the constructor that produced it is still on the caller’s stack.
The object itself carries almost no information. std::thread::id is opaque
and comparable only for equality and ordering; joinable() reports whether the
thread still has an operating-system thread backing it (it does, until you
join() or detach() it, after which the object is a shell). get_id()
returns the id; hardware_concurrency() is a static hint, not a guarantee,
about how many threads can usefully run in parallel on the host. None of this
is a critique of std::thread so much as a description of its scope: it is a
thin, portable handle to a native thread, nothing more. Everything else
(naming a thread, pinning it to a core, setting its scheduling policy) lives
one level down, reachable only through native_handle().
C++20’s std::jthread (“joining thread”) is a strict superset built to close
the one gap in that design that actually caused bugs in practice: a
std::thread that is still joinable when its destructor runs calls
std::terminate(). That is a deliberate choice: the committee would rather
crash loudly than leak a running thread silently. But it means every early
return, every exception, every forgotten join() on a control path nobody
tested is a live grenade. jthread’s destructor instead requests a stop and
joins, unconditionally, with no way to opt out short of calling detach()
yourself first. A function that constructs a jthread, does some work, and
returns through five different branches no longer needs five matching
join() calls; it needs none.
std::jthread worker([] {
do_work();
});
// no explicit join anywhere below; every return path is safe
The second half of jthread’s contribution is that its callable may accept a
std::stop_token as its first parameter, supplied automatically by the
jthread that constructed it. That parameter is what turns “the destructor
will join” into “the destructor will also ask nicely first,” and it is the
subject of the next section.
Given both types exist, the practical rule is short: default to jthread.
Reach for std::thread only when you have a concrete reason to manage the
stop/join contract by hand — interoperating with an API that expects a bare
std::thread, for instance, or building your own thread-pool primitive where
you want the termination behavior to be explicit rather than implicit.
stop_token and stop_source
Before C++20, cancelling a running thread cooperatively meant inventing the
mechanism yourself: an std::atomic<bool> flag, captured by reference, polled
in the worker’s loop, set by whoever wants the thread to stop. It works, and
it has worked in C++ codebases for two decades, but every team reinvents its
own flavor — some wrap it in a class, some pass it raw, some forget to make it
atomic and get a loop that never notices the flag changed underneath an
optimizer that cached the read.
std::stop_source and std::stop_token standardize that pattern. A
stop_source is the write side: calling request_stop() on it sets shared
state and returns true the first time it does so (subsequent calls are
no-ops that return false). A stop_token, obtained from a stop_source via
get_token(), is the read side: stop_requested() polls the same shared
state, and stop_possible() reports whether any stop_source capable of
requesting a stop still exists. The two sides share reference-counted state
behind the scenes, so a stop_token remains valid and answerable even after
every stop_source that could have produced it has gone out of scope — it
reports stop_possible() == false from then on.
A jthread owns a stop_source internally and threads a stop_token from it
into the callable’s first parameter when one is accepted. request_stop() on
the jthread object forwards to that internal source, and so does the
destructor, automatically, before it joins.
// C++20
#include <thread>
#include <stop_token>
#include <chrono>
#include <atomic>
void worker(std::stop_token tok, std::atomic<long>& loops) {
while (!tok.stop_requested()) {
loops.fetch_add(1, std::memory_order_relaxed);
std::this_thread::sleep_for(std::chrono::milliseconds(5));
}
}
void run_cooperative() {
std::atomic<long> loops{0};
std::jthread t(worker, std::ref(loops)); // starts, owns its stop_source
std::this_thread::sleep_for(std::chrono::milliseconds(200));
} // ~jthread(): request_stop() then join() -- every return path is safe
// C++23 -- no new stop_token API; move_only_function widens what a jthread
// can run, which matters once the callable owns non-copyable state
#include <thread>
#include <stop_token>
#include <functional>
#include <memory>
void run_with_owned_state(std::unique_ptr<int> state) {
std::move_only_function<void(std::stop_token)> task =
[state = std::move(state)](std::stop_token tok) {
while (!tok.stop_requested()) {
*state += 1;
}
};
std::jthread t(std::move(task)); // std::function could not hold this
} // same destructor contract as C++20: request_stop() then join()
// C++26 -- std::execution (P2300), targeted for C++26; not yet shipping in
// mainline GCC or clang as of this writing -- proposed/experimental
#include <stop_token>
#include <execution>
void run_in_place(std::inplace_stop_source& src) {
std::inplace_stop_token tok = src.get_token();
// inplace_stop_source/inplace_stop_token hold their shared state inline
// rather than behind jthread's heap-allocated, atomically refcounted
// stop_state -- the allocation-free alternative a sender/receiver
// pipeline needs when a stop check sits on a hot path.
}
The C++26 column names a real gap. std::stop_token’s shared state is
allocated on the heap and managed by atomic reference counts, which is the
right default for a general-purpose type but is exactly the kind of cost a
low-latency pipeline wants to avoid on every request. std::execution’s
inplace_stop_source trades that generality for a token whose state lives
wherever its owner places it (on the stack, inside another object) at the
cost of a stricter lifetime contract: the source must outlive every token and
callback derived from it, with no refcounting to paper over a mistake.
A stop_token is cooperative, and that limitation shows up the first time a
thread is blocked rather than looping. A thread parked inside read(2),
waiting on a condition variable with no timeout, or spinning in a library
call that never checks the token will not stop no matter how many times you
call request_stop(). The token only helps a thread that polls it. For a
thread that must be interrupted out of a blocking syscall, a different
mechanism is needed entirely: a self-pipe, a pidfd, a timeout on the wait
itself. stop_token does not pretend to replace them.
Thread Lifetime and Ownership
A std::thread or std::jthread object is the only handle a program has to a
running operating-system thread, and that handle is move-only: copying it
would leave two objects claiming ownership of one thread, which the type
system refuses to allow. Moving transfers ownership cleanly — the source
object becomes non-joinable, the destination becomes responsible for the
thread’s eventual join() or detach().
The rule that follows from this is unforgiving on std::thread: if the
object is destroyed while still joinable, the program calls std::terminate()
immediately, with no unwinding, no destructors further up the stack, nothing.
This is not a defect to work around with a try/catch; std::terminate()
is specifically not an exception, so catching around the scope changes
nothing. The only ways to avoid it are to join(), to detach(), or to move
the thread object somewhere that will do one of those for you — which is
precisely what std::jthread’s destructor does automatically.
The ownership question that actually causes outages is not the destructor
rule itself but what the thread touches while it runs. A worker that captures
local variables by reference from the function that spawned it is safe only
as long as that function’s stack frame is still alive when the worker reads
them. A std::jthread that is itself a local variable enforces this
correctly, because its destructor blocks until the worker has finished before
the frame unwinds further. A thread that has been detach()-ed, or one whose
handle has been moved into a longer-lived container while its captures still
point at a short-lived stack frame, enforces nothing: the references dangle
the moment the original frame returns, and the worker reads freed memory with
no indication anything went wrong until it crashes somewhere unrelated.
The safe default is to make captured state outlive the thread by construction
— capture by value, hold shared state in a shared_ptr, or keep the thread
object itself scoped tightly enough that its lifetime visibly bounds its
captures’ lifetime. Reviewing a thread’s captures and reviewing its join
discipline are the same review.
Joining vs Detaching
join() blocks the calling thread until the target has finished, and it is
the only operation other than detach() that a joinable thread object can
leave to its destructor without crashing the program. After join() returns,
the thread object is no longer joinable, and the operating-system resources
backing it have been reclaimed.
detach() severs the connection outright: the thread keeps running
independently, the object immediately becomes non-joinable, and nothing in
the program retains a way to wait for it, cancel it, or learn when it exits.
That independence is also detachment’s entire cost. A detached thread that
outlives main() is terminated by the runtime without running its
destructors to completion; a detached thread that holds a reference into a
stack frame that returns is a dangling reference with no destructor around to
catch the mistake; a detached thread that throws an unhandled exception calls
std::terminate() just as a joined one would, except there is no owning
object left anywhere to have prevented the thread from being abandoned in a
half-finished state in the first place.
There are legitimate uses, such as a fire-and-forget background
task whose owning process is about to exit regardless, or an old-style API
boundary that only accepts a bare thread id. But in service code, where a
request’s
lifetime is supposed to be observable and a shutdown path is supposed to be
clean, detach() is close to never the right call. Prefer join(), and
prefer jthread so that the join happens whether or not the code that wrote
the function remembered to ask for it.
Running in a container
hardware_concurrency() reads the host’s advertised CPU count, and under a
cgroup v2 CPU quota that number does not shrink to match the quota. A
container limited to cpus: "2" on a 64-core host still reports
hardware_concurrency() == 64, because the function reads /proc/cpuinfo and
affinity masks, not cpu.max. A thread pool sized naively off that return
value will happily create sixty-four jthreads, all of which the scheduler
then time-slices across two CPU-quota’s worth of runtime — more context
switching, not more throughput. Size pools from cpu.max (or, if you control
the orchestrator, from a fixed, explicitly-injected worker count) rather than
trusting hardware_concurrency() inside a quota-limited container.
A cpuset constraint behaves differently, and the two are worth keeping
separate. Pinning a container to specific cores with cpuset.cpus changes
what sched_getaffinity() reports for every thread in it, and jthreads
created after that point inherit the narrowed mask. On glibc 2.36 and newer
(released 2022), hardware_concurrency() tracks this too: libstdc++
implements it through get_nprocs() / sysconf(_SC_NPROCESSORS_ONLN), which
on current glibc reads the calling thread’s affinity mask rather than a raw
hardware count, so it shrinks to match a cpuset.cpus restriction on those
systems. That is the opposite of the CFS-quota case above: a cpuset
affinity change is visible to hardware_concurrency() on modern glibc, while
a CFS bandwidth quota (cpu.max, or docker --cpus=N, which is implemented
as a quota rather than a cpuset) never is, because quota throttling doesn’t
touch the affinity mask at all — every CPU stays reachable, and the quota
only limits how much wall-clock time the cgroup gets to run on them. The
practical rule: know which kind of limit a container enforces before
trusting hardware_concurrency() under it, and on an older glibc (pre-2.36,
where the affinity-tracking behavior described here doesn’t exist yet) treat
cpuset restrictions the same way as a quota, as a limit this function
cannot see.
Clean shutdown is where jthread pays for itself under an orchestrator.
Kubernetes and plain docker stop both send SIGTERM and then, after a
grace period, SIGKILL. A process whose signal handler calls
request_stop() on every outstanding jthread and then returns lets each
worker’s stop_token loop notice on its next poll and exit cleanly, and lets
main()’s jthread destructors join them all before the process exits on
its own — well inside the grace period, with no forced kill and no resources
abandoned mid-write.
The runnable version of that shutdown path lives in
examples/concurrency/04-jthread-shutdown/.
Standards: C++20 / C++23 / C++26
C++20 is where this chapter’s whole toolkit arrives at once: std::jthread
itself, std::stop_token, std::stop_source, and std::stop_callback (a
RAII type that registers a callback to run the moment a stop is requested,
useful for waking a thread parked in something other than a polling loop).
Before C++20, none of this existed in the standard library; cooperative
cancellation meant a hand-rolled flag, and automatic joining meant a hand-rolled
RAII wrapper around std::thread.
C++23 adds no new thread or cancellation primitive. What it contributes
indirectly is std::move_only_function, which matters here because it lets a
jthread (or any callback-taking API) accept a closure that owns a
non-copyable resource, such as a unique_ptr or a file handle wrapper,
without first smuggling it through a shared_ptr just to satisfy std::function’s
copyability requirement. The cancellation model itself is unchanged; the
ergonomics of what you can hand to it improved.
C++26 has no shipping change to jthread or stop_token either, but
std::execution (the sender/receiver framework from P2300, voted for C++26)
introduces std::inplace_stop_source and std::inplace_stop_token as a
deliberately lower-level sibling: the same request_stop() /
stop_requested() contract, without the heap allocation and atomic
reference counting behind std::jthread’s version. As of this writing it is
proposed and experimental: mainline GCC and clang have not shipped a
complete std::execution implementation, and the ordinary stop_token path
described above remains the one to reach for until that changes.
What we learned
std::threadstarts running at construction, is move-only, and callsstd::terminate()if it is destroyed while still joinable — a design that turns a forgotten join into a crash rather than a leak.std::jthreadcloses that gap: its destructor requests a stop and joins, unconditionally, which is why it should be the default andstd::threadthe exception that needs a reason.std::stop_source/stop_tokenstandardize cooperative cancellation as a shared, reference-counted flag pair;request_stop()only ever takes effect once, andstop_requested()can be polled safely from any thread.- Cancellation is cooperative only: a thread blocked in a syscall that never
checks its token will not stop, and
stop_tokendoes not claim otherwise. - A thread’s captured references are safe only as long as the frame they
point into outlives the thread;
jthread’s blocking destructor is what usually makes that true without extra bookkeeping. detach()gives up every means of waiting for, cancelling, or observing a thread’s exit, and is close to never the right default in service code.hardware_concurrency()does not shrink under a cgroup CPU quota; size worker pools from the quota or an injected count, not from that return value.