Concurrency[conc]
std::thread and std::jthread: start, join and stop a thread
- Section
- Concurrency [conc]
- Standard
- C++20
- Compilers
- MSVC 19.44, Clang 19.1 (MSVC STL), GCC 16.2, Clang 23.1 (libc++)
- Published
- Length
- 5 min read
In this answer10 clauses
- 1Start a thread with std::jthread
- 2Forget to join a std::thread and the program ends
- 3Pass arguments: copies, std::ref and lambda captures
- 4Get a result back from a thread
- 5An exception that leaves a thread ends the program
- 6Stop a thread: std::stop_token and request_stop()
- 7join, detach and joinable
- 8Sleep, thread ids and hardware_concurrency
- 9What you need to build it
- 10std::thread or std::jthread: which one to write
Abstract
Start a thread in C++20 by constructing a std::jthread from a function and its arguments; its destructor requests a stop and joins the thread. Arguments are copied in the calling thread, so pass a reference with std::ref. A std::thread (C++11) must be joined on every path: destroying one that is still joinable calls std::terminate, and so does an exception that leaves the thread’s function, with either class.

To start a thread in C++20, construct a std::jthread from a function and its arguments: std::. The function runs on a new thread, its arguments are copied in the calling thread first (std::ref passes a reference), and when worker goes out of scope its destructor asks the thread to stop and joins it. std::thread (C++11) starts a thread the same way but must be joined by hand: destroying one that is still joinable calls std::terminate. So does an exception that leaves the thread’s function, with either class.
This answer is written for C++20, which added std::jthread, std::stop_token and std::stop_callback (P0660R10) to the C++11 std::thread. C++23 reworded how the constructor copies its arguments and added a std::formatter for std::thread::id; thread attributes such as std:: came after C++26, in the working draft. cppreference’s std::jthread page lists every member. Since When’s C++20 table gives the first releases of std::jthread, and its C++11 table the support for std::thread.
The samples were built with four compilers on three standard libraries: MSVC 19.44 and Clang 19.1 on Microsoft’s STL (Windows x64), GCC 16.2 with libstdc++ and Clang 23.1 with libc++ (Compiler Explorer, Linux x86-64). Each output shown is what all four printed, unless the text says otherwise. The std::thread samples are C++11 code, built as C++14 on all four (MSVC has no C++11 mode) and also as C++11 with GCC and libc++.
The Linux builds of the samples that end normally also ran clean under ThreadSanitizer, one of the sanitizers on the Toolchain page.
What changed: a thread is started with std::thread, and join() on every pathstd::jthread, which joins itself.
since C++20
Start a thread with std::jthread
Any callable starts a thread: a function and its arguments, a lambda or another of the function objects C++ has, or a member function followed by a pointer to its object. The running example processes jobs, each an id and a size in bytes:
struct Job {
int id;
std::size_t size;
};
void process(const Job& job) {
std::cout << "job " << job.id << ": " << job.size << " bytes\n";
}
// ...
{
std::jthread worker(process, Job{1, 4096}); // started
} // ~jthread(): waits for the thread here
std::cout << "main: job 1 is done\n";
// ...
{
Job job{2, 512};
std::jthread worker([job] { process(job); });
}
// ...
Worker worker(std::vector<Job>{{3, 128}, {4, 64}});
{
std::jthread t(&Worker::run, &worker);
}
// ...
std::thread t(process, Job{5, 1024});
t.join(); // std::thread: join by hand
std::cout << "main: joined\n";— end example]
Worker::run() calls process() on each of its own jobs. It printed:
job 1: 4096 bytes
main: job 1 is done
job 2: 512 bytes
job 3: 128 bytes
job 4: 64 bytes
job 5: 1024 bytes
main: joined— end example]
~jthread() calls request_stop(), then join() ([thread.jthread.class]); std::thread takes the same arguments and leaves the join() to you.
Forget to join a std::thread and the program ends
Destroying a std::thread that is still joinable calls std::terminate ([thread.thread.destr]). The shortest way there:
int main() {
std::thread worker(process, Job{1, 4096});
std::cout << "leaving main" << std::endl;
} // ~thread(): still joinable, so std::terminate()— end example]
All four printed leaving main; what came after is each runtime’s own:
| Runtime | Message | Exit status |
|---|---|---|
| Microsoft’s STL, release build (MSVC, Clang 19.1) | nothing | 0xC0000409 |
Microsoft’s STL, debug build (MSVC, /MDd) |
abort() has been called, in a dialog box |
3 |
| libstdc++ (GCC 16.2) | terminate called without an active exception |
134, SIGABRT |
| libc++ (Clang 23.1) | libc++abi: terminating |
134, SIGABRT |
No tool caught it: no warning at /W4 or -Wall -Wextra, nothing from MSVC’s /analyze, and style findings only from clang-tidy 19.1.5 with every check on.
The usual way there: the join() is written, but an exception or an early return skips it:
std::size_t total_bytes(const std::vector<Job>& jobs) {
std::size_t bytes = 0;
std::thread worker([&] {
for (const Job& job : jobs)
bytes += job.size;
});
check(jobs); // throws: worker.join() is never reached
worker.join();
return bytes;
}— end example]
check() throws for an empty job, and the caller’s catch (const std:: never ran: unwinding destroyed the joinable worker first, and the release and Linux builds ended as above. libstdc++ printed terminate called without an active exception here too: to it an exception is active only once a catch has it ([except.handle]), and this one was still on its way to the caller’s handler. The message points away from the cause.
As a std::jthread, worker joins on the way out and the exception reaches the caller: all four printed caught: job 2 is empty.
| Property | std::thread |
std::jthread |
|---|---|---|
| Standard | C++11 | C++20 |
| Destroyed while joinable | calls std::terminate |
request_stop(), then join() |
| Move-assigned over while joinable | calls std::terminate |
request_stop(), then join() |
| Can be asked to stop | no | yes, request_stop() |
| Copy and move | move-only | move-only |
| Size, Microsoft’s STL on x64 | 16 bytes | 24 bytes |
| Size, libstdc++ and libc++ on x86-64 | 8 bytes | 16 bytes |
Pass arguments: copies, std::ref and lambda captures
The constructor copies (or moves) every argument in the calling thread, and the function receives those copies ([thread.thread.constr]). A const& parameter binds to the thread’s copy unless the argument is wrapped in std::cref:
void tally(const std::vector<Job>& jobs, std::size_t& bytes) {
for (const Job& job : jobs)
bytes += job.size;
}
// ...
std::thread first(tally, jobs, std::ref(bytes)); // jobs: a copy
jobs.push_back({3, 128}); // first counts its own copy
first.join();
std::cout << "first: " << bytes << " bytes\n";
// ...
bytes = 0;
std::thread second(tally, std::cref(jobs), std::ref(bytes));
second.join(); // jobs stays untouched until here
std::cout << "second: " << bytes << " bytes\n";— end example]
It printed first: 4608 bytes and second: 4736 bytes. A non-const reference parameter without std::ref does not compile:
std::thread worker(tally, jobs, bytes); // error: needs std::ref— end example]
No compiler’s message names std::ref. GCC’s comes closest:
error: static assertion failed: std::thread arguments must be invocable after conversion to rvalues— end example]
libc++ fails a static_assert with an empty message; MSVC reports error C2672: 'invoke': no matching overloaded function found.
Counted with a Batch type whose constructors record the thread they run on, all three libraries did the same:
| Parameter | Argument | Copies and moves |
|---|---|---|
Batch |
batch |
1 copy in the calling thread, 1 move in the new thread |
const Batch& |
batch |
1 copy in the calling thread |
const Batch& |
std::move(batch) |
1 move in the calling thread |
So a copy constructor that throws does so in the calling thread: the constructor throws, no thread starts, and the caller’s catch gets it (all four, in every mode built). A move-only argument goes in with std::move: std:: handed a std::unique_ptr<Job> to the thread and left main’s pointer empty.
A lambda that captures by reference shares the caller’s objects instead, and their lifetime is then yours (CP.31: pass small amounts of data by value). A jthread joins in its destructor, and locals are destroyed in reverse order of declaration, so what the thread uses comes first:
struct Totals {
std::size_t bytes = 0;
~Totals() { std::cout << "totals destroyed\n"; }
};
void run(const std::vector<Job>& jobs) {
Totals totals; // declared first: destroyed last
std::jthread worker([&] {
for (const Job& job : jobs)
totals.bytes += job.size;
std::cout << "worker done: " << totals.bytes << " bytes\n";
});
} // ~jthread() joins the worker, then ~Totals() runs— end example]
All four printed worker done: 4736 bytes before totals destroyed. Here the compiler enforces the order: declared after worker, totals cannot be captured at all, and all four rejected it.
A class is where it bites. Members are constructed in declaration order and destroyed in reverse ([class.base.init], [class.dtor]), so a std::jthread member declared before the members its thread uses starts before they are constructed and joins after they are destroyed. A thread that touches them in either window has undefined behavior:
class Pipeline {
// ...
private:
std::jthread worker; // declared first: destroyed last
Totals totals; // declared last: destroyed first
};— end example]
This thread never touches totals: it waits for its stop, then prints. All four printed totals destroyed before worker: stop requested. Declare the jthread member last.
Get a result back from a thread
The function’s return value is thrown away. Write the result into an object the caller owns, one slot per thread, and read it after the join: the end of the thread synchronizes with the return of join() ([thread.thread.member]), which is what makes the read safe. total() adds up the sizes in a span of jobs:
std::span<const Job> all(jobs);
std::size_t half = all.size() / 2;
std::array<std::size_t, 2> bytes{}; // one slot per thread
{
std::jthread a([&] { bytes[0] = total(all.first(half)); });
std::jthread b([&] { bytes[1] = total(all.subspan(half)); });
} // both joined: now the slots can be read
std::cout << bytes[0] << " + " << bytes[1] << " = "
<< bytes[0] + bytes[1] << " bytes\n";— end example]
It printed 4608 + 2176 = 6784 bytes. Two threads writing one object, or a read of a slot before its thread is joined, with no mutex, atomic or join to order them, is a data race, and a data race is undefined behavior ([intro.races]). Share data through a std::mutex or a std::atomic; for one result from one task, std::async hands it back in a future.
For any number of threads, fill a std:: with emplace_back; its destructor joins every one:
std::vector<std::size_t> sizes{4096, 512, 128, 2048};
std::vector<std::size_t> doubled(sizes.size()); // one slot per thread
{
std::vector<std::jthread> workers;
for (std::size_t i = 0; i < sizes.size(); ++i)
workers.emplace_back([&, i] { doubled[i] = sizes[i] * 2; });
} // ~vector(): every ~jthread() joins— end example]
All four printed the sum, 13568 bytes.
A jthread does not make the result readable at return: the return expression is evaluated before the locals’ destructors run. This worker loops until its stop is requested:
std::size_t read(const std::size_t& bytes) {
std::cout << "return: bytes read\n";
return bytes;
}
std::size_t total_bytes() {
std::size_t bytes = 0;
std::jthread worker([](std::stop_token token) {
while (!token.stop_requested())
std::this_thread::sleep_for(std::chrono::milliseconds(5));
std::cout << "worker: stopped by ~jthread()\n";
});
return read(bytes); // evaluated before ~jthread() runs
}— end example]
All four printed return: bytes read before worker: stopped by ~jthread(). A function that returns what its worker wrote calls worker.join() before the return, jthread or not.
An exception that leaves a thread ends the program
An exception that leaves the thread’s function calls std::terminate ([thread.thread.constr]), with std::jthread too: the same end as an exception thrown out of a noexcept destructor, which a destructor is by default since C++11. A try around the constructor cannot catch it, because it is thrown on the other thread:
void process(const Job& job) {
if (job.size == 0)
throw std::invalid_argument("job " + std::to_string(job.id)
+ " is empty");
}
int main() {
std::cout << "starting job 2" << std::endl;
try {
std::jthread worker(process, Job{2, 0});
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n'; // never printed
}
}— end example]
All four printed starting job 2. libstdc++ then printed:
terminate called after throwing an instance of 'std::invalid_argument'
what(): job 2 is empty— end example]
libc++ printed libc++abi: terminating due to uncaught exception of type std::; both ended with SIGABRT. Microsoft’s release runtime again printed nothing and exited with 0xC0000409.
Catch inside the function, and hand the exception to the caller as a std::exception_ptr, rethrown after the join:
std::exception_ptr error; // written by the worker only
{
std::jthread worker([&error] {
try {
process(Job{2, 0});
} catch (...) {
error = std::current_exception();
}
});
} // joined: now error can be read
try {
if (error)
std::rethrow_exception(error);
} catch (const std::exception& e) {
std::cout << "caught in main: " << e.what() << '\n';
}— end example]
All four printed caught in main: job 2 is empty.
Stop a thread: std::stop_token and request_stop()
The standard library has no call that kills a thread (that would be a platform call on native_handle()); stopping is cooperative. A jthread passes its own std::stop_token to a function whose first parameter takes one; request_stop(), or the destructor, sets it:
void serve(std::stop_token token, int id) {
using namespace std::chrono_literals;
while (!token.stop_requested())
std::this_thread::sleep_for(5ms); // look for work
std::cout << "worker " << id << ": stop requested\n";
}
// ...
std::jthread first(serve, 1); // runs serve(token, 1)
first.request_stop();
first.join();
std::cout << "main: worker 1 joined\n";
// ...
{
std::jthread second(serve, 2);
} // ~jthread(): request_stop(), then join()
std::cout << "main: worker 2 joined\n";— end example]

A member function that takes a std::stop_token does not receive it from std::: the token would have to come before the object, and all four rejected the call. A lambda that takes the token and passes it on worked on all four: std::.
A worker asleep in std:: is woken by the stop request: the call returns at once with the value of pred() ([thread.condvarany.intwait]):
std::mutex mutex;
std::condition_variable_any job_added;
std::vector<Job> queue; // guarded by mutex
std::size_t bytes = 0; // the worker's; read after join
std::jthread worker([&](std::stop_token token) {
std::unique_lock lock(mutex);
while (job_added.wait(lock, token,
[&] { return !queue.empty(); })) {
bytes += queue.back().size;
queue.pop_back();
}
// wait() returned false: a stop was requested
std::cout << "worker: stopped, " << queue.size()
<< " jobs waiting\n";
});
// ...
worker.request_stop(); // wakes the wait
worker.join();
std::cout << "main: " << bytes << " bytes processed\n";— end example]
With one 4096-byte job queued before the stop, all four printed worker: stopped, 0 jobs waiting. Plain std:: has no such overload: all four rejected the call.
To run code at the moment of the stop, register a std::stop_callback on the token. Registered before the stop, it runs inside request_stop(), on the requesting thread; registered after, it runs at once, in its own constructor. Here serve takes the token alone:
const std::thread::id main_id = std::this_thread::get_id();
std::jthread worker(serve);
std::stop_callback on_stop(worker.get_stop_token(), [&] {
bool here = std::this_thread::get_id() == main_id;
std::cout << "on_stop runs in main's thread: "
<< (here ? "yes" : "no") << '\n';
});
std::cout << "requesting a stop\n";
worker.request_stop(); // runs on_stop before it returns
std::cout << "request_stop() returned\n";
std::cout << "registering a callback after the stop\n";
std::stop_callback late(worker.get_stop_token(), [] {
std::cout << "late: runs at once, in its constructor\n";
});
std::cout << "late is constructed\n";
worker.join();
std::cout << "main: joined\n";— end example]
It printed:
requesting a stop
on_stop runs in main's thread: yes
request_stop() returned
registering a callback after the stop
late: runs at once, in its constructor
late is constructed
main: joined— end example]
join, detach and joinable
joinable() returns get_id() != std::: true while the object owns a thread it has not joined or detached ([thread.thread.member]). All four agreed in every state:
| State | joinable() |
|---|---|
| Default-constructed | false |
| Started | true |
After join() |
false |
After detach() |
false |
| Moved from | false |
| Moved to | true |
A second join() throws std::system_error with errc:: on all four. Only what() differs:
- Microsoft’s STL:
invalid argument: invalid argument - libstdc++:
Invalid argument - libc++:
thread::join failed: Invalid argument
join() has no timeout. To wait at most so long, request a stop, or wait with wait_for on a condition variable or a future.
After detach() the object no longer represents the thread, and no join() can wait for it: only a signal the thread sends itself, through a condition variable or a future, tells you it ended. Hence the Core Guidelines’ CP.26, “Don’t detach() a thread”: a detached thread is hard to monitor and to communicate with.
When main returns, std::exit destroys the static objects while a detached thread may still run; if the thread then uses one of them, or most of the standard library (std::cout included), the behavior is undefined ([basic.start.term]). The standard does not say whether the thread is stopped.
Sleep, thread ids and hardware_concurrency
A thread acts on itself through std::this_thread, and on the thread an object owns through its members:
| Call | What it does |
|---|---|
std:: |
blocks the calling thread for a duration |
std:: |
blocks it until the time point t |
std:: |
offers the implementation a chance to reschedule |
std:: |
the calling thread’s id |
worker.get_id() |
the id of the thread worker owns, or std::thread::id() |
std:: |
a hint; should be 0 when it cannot be computed |
worker.native_handle() |
the platform’s handle, for priority, affinity or a name |
An id is unique among threads that are running or can still be joined; the library may reuse the id of a thread that has ended and can no longer be joined ([thread.thread.id]). Sleeping is not a way to wait for another thread: join() waits for it to end, and a condition variable waits for it to signal.
What you need to build it
std::thread and std::jthread are in <thread>, the stop tokens in <stop_token>. As C++17, all four rejected std::jthread; GCC adds note: 'std::. As C++20, __cpp_lib_jthread was 201911 on all three libraries. The first releases with std::jthread:
| Library | First release |
|---|---|
| libstdc++ | GCC 10.1 |
| libc++ | 20; from 18 behind -fexperimental-library |
| Microsoft’s STL | Visual Studio 2019 16.9 |
Clang 23.1 with libc++ built every sample without -fexperimental-library. On Linux, no build passed -pthread, and all ran: Compiler Explorer’s glibc is 2.39, and since 2.34 libpthread is part of libc (the glibc 2.34 announcement: “New applications do not need to link with -lpthread”). On glibc 2.33 or older, g++ without the flag fails the link with undefined reference to 'pthread_create'.
std::thread or std::jthread: which one to write
| You need | Write |
|---|---|
| A worker thread in C++20 | std::jthread, with a std::stop_token parameter if it loops |
| A thread in C++11 to C++17 code | std::thread with join() on every path, or a joining wrapper such as gsl::joining_thread (CP.25) |
| One result from one task | std::async and its future |
| Data that several threads write | a std::mutex or a std::atomic |
| A thread that outlives the function that starts it | a jthread owned by an outer scope, not detach() (CP.26) |
Before the review:
- Every
std::threadis joined on every path, exceptions included, or is astd::jthread. - References go in through
std::reforstd::cref; every other argument is copied first. - What a thread uses by reference is declared before the thread.
- A result is read after
join(), from a slot only that thread writes. - The thread’s function catches its own exceptions, and a
std::exception_ptrcarries one out.
More answers about threads and shared data are in Concurrency.
Questions
What is the difference between std::thread and std::jthread?
Two things. Destroying or move-assigning over a std::thread that is still joinable calls std::terminate, while a joinable std::jthread calls request_stop() and then join() in both cases. And a std::jthread owns a stop source: its function can take a std::stop_token as its first parameter and test stop_requested(). The rest of the interface (join(), detach(), joinable(), get_id()) is the same; std::thread is C++11, std::jthread C++20.
How do I pass a member function to std::thread?
Pass the member function pointer, then a pointer to the object, then the function’s own arguments: std::jthread t(&Worker::run, &worker); runs worker.run() on the new thread, and std::thread takes the same arguments. Only the pointer is copied, not the object, so the object must outlive the thread. A run() that takes a std::stop_token does not get the jthread’s token this way: start it with a lambda that takes the token and calls worker.run(token).
How many threads can I create, and what does hardware_concurrency return?
std:: returns a hint, and should return 0 when the value is not computable or not well defined; it returned 2 in Compiler Explorer’s sandbox and another number on a Windows x64 machine. It is not a limit. When no more threads can be started, the std::thread or std::jthread constructor throws std::system_error with errc::.
Do I need -pthread to use std::thread?
Not with glibc 2.34 or later, which moved libpthread into libc; its release announcement says new applications do not need to link with -lpthread. On a glibc 2.39 system, GCC 16.2 and Clang 23.1 with libc++ built and ran threads without -pthread, and a GCC -static build ran too. On glibc 2.33 or older, g++ without the flag fails the link with undefined reference to ‘pthread_create’.
Can I kill or cancel a std::thread?
No. Neither std::thread nor std::jthread has a call that ends another thread: stopping is cooperative. A std::jthread passes a std::stop_token to its function, request_stop() sets it, and the function returns when its loop sees stop_requested(). A thread blocked in std::(lock, token, pred) wakes when the stop is requested.
Is std::thread copyable?
No. std::thread and std::jthread are move-only: moving one hands over the thread it owns, and the moved-from object owns none, so its joinable() returns false. Move-assigning onto a std::thread that is still joinable calls std::terminate; a joinable std::jthread calls request_stop() and join() first.
Sources
- std::threadcppreference.com
- std::jthreadcppreference.com
- [thread.thread.constr] ConstructorsC++ standard working draft
- [thread.thread.destr] DestructorC++ standard working draft
- [thread.jthread.class] Class jthreadC++ standard working draft
- CP.25: Prefer gsl::joining_thread over std::threadC++ Core Guidelines
- CP.26: Don’t detach() a threadC++ Core Guidelines
- The GNU C Library version 2.34 is now availablelibc-alpha mailing list