C++20 std::jthread: Auto-Join, Cooperative Cancellation with stop_token, and Its Limits

Key takeaways

std::jthread fixes std::thread's most dangerous default: destroying a joinable thread no longer calls std::terminate. Its destructor requests a stop and then joins. That only helps if the thread's code actually checks its stop_token, and this guide covers how to make waits and sleeps respond to it.

Why jthread exists

std::jthread in C++20 is a thread class that provides automatic join and a cooperative stop mechanism, following RAII principles.

The problem it solves is a harsh default in std::thread. If a std::thread object is destroyed while it is still joinable (you called neither join() nor detach()), its destructor calls std::terminate(), and the whole program aborts. The committee chose this deliberately: silently joining could hang the program, and silently detaching could leave a thread running with references to destroyed objects, so crashing was considered the least bad option. In practice, it means any early return or exception between creating a thread and joining it crashes the process, and exception-safe std::thread code needs a guard object around every thread.

std::jthread (“joining thread”) changes the destructor: it first calls request_stop(), then join(). The first step is as important as the second. A joining destructor alone would hang whenever the thread runs an endless loop, so jthread also gives each thread a std::stop_source, and the thread function can accept a std::stop_token to find out that it has been asked to finish. The name of the mechanism matters: the stop is cooperative. Nothing interrupts the thread. It stops only if its own code checks the token.


jthread basics

std::thread vs std::jthread

#include <iostream>
#include <thread>
#include <chrono>

using namespace std::chrono_literals;

// std::thread: manual join required
void useThread() {
    std::thread t([] {
std::cout << "std::thread operation" << std::endl;
    });
    
t.join();  // essential! If not, call std::terminate
}

// std::jthread: auto join
void useJthread() {
    std::jthread jt([] {
std::cout << "std::jthread operation" << std::endl;
    });
    
// Automatically joined in destructor
}

int main() {
    useThread();
    useJthread();
}

Comparison table

Featuresstd::threadstd::jthread
auto join❌ (requires manual)✅ (in destructor)
suspension mechanism❌✅ (stop_token)
RAII❌✅
C++ versionC++11C++20

Key Concepts:

  • RAII: Automatically clean up resources in destructor.
  • Safety: Prevent terminate due to missing join()
  • Convenience: No explicit join required

Basic use

Simple example

#include <iostream>
#include <thread>
#include <chrono>

using namespace std::chrono_literals;

void simpleTask() {
std::cout << "start operation" << std::endl;
    std::this_thread::sleep_for(1s);
std::cout << "Operation completed" << std::endl;
}

int main() {
std::cout << "start main" << std::endl;
    
    {
        std::jthread t(simpleTask);
std::cout << "Thread created" << std::endl;
        // Auto-join when out of scope
    }
    
std::cout << "main end" << std::endl;
}

Passing parameters

#include <iostream>
#include <thread>

void printNumbers(int start, int end) {
    for (int i = start; i <= end; i++) {
        std::cout << i << " ";
    }
    std::cout << std::endl;
}

int main() {
    std::jthread t1(printNumbers, 1, 5);
    std::jthread t2(printNumbers, 10, 15);
    
// auto-joined
}

Stopping with stop_token

Default abort mechanism

#include <iostream>
#include <thread>
#include <chrono>

using namespace std::chrono_literals;

void worker(std::stop_token stoken) {
    int count = 0;
    
    while (!stoken.stop_requested()) {
std::cout << "Working... (" << count++ << ")" << std::endl;
        std::this_thread::sleep_for(100ms);
    }
    
std::cout << "aborted" << std::endl;
}

int main() {
    std::jthread t(worker);
    
    std::this_thread::sleep_for(1s);
    
std::cout << "Abort request" << std::endl;
    t.request_stop();  // request to stop
    
    // Automatic join (in destructor)
}

How does the token get into worker? When you construct a jthread with a callable, it checks whether the callable can be invoked with a std::stop_token as its first argument, followed by your arguments. If so, it passes its own token. If not, it calls the function with just your arguments, and the thread has no way to observe stop requests at all. That is why std::stop_token must be the first parameter. Declaring it anywhere else compiles, but the parameter then has to be passed explicitly and no longer refers to the jthread’s own stop state.

The explicit request_stop() call in main is not strictly needed here, since the destructor would make the same request. Calling it explicitly is still useful when you want to stop several threads first and join them afterwards, so they shut down in parallel rather than one after another.

Note the response time: the loop checks the token once per iteration, and each iteration sleeps 100 ms, so shutdown can take up to 100 ms. sleep_for cannot be interrupted by a stop request. For long waits, use a condition variable wait that accepts the token (Example 3 below), which wakes up immediately when a stop is requested.

Utilizing stop_token

#include <iostream>
#include <thread>
#include <chrono>
#include <atomic>

using namespace std::chrono_literals;

void dataProcessor(std::stop_token stoken) {
    std::atomic<int> processed{0};
    
    while (!stoken.stop_requested()) {
// data processing
        processed++;
        
// Check for interruption periodically
        if (processed % 100 == 0) {
std::cout << "Processed data: " << processed << std::endl;
        }
        
        std::this_thread::sleep_for(10ms);
    }
    
std::cout << "Final processed: " << processed << std::endl;
}

int main() {
    std::jthread t(dataProcessor);
    
    std::this_thread::sleep_for(2s);
    t.request_stop();
}

The std::atomic<int> here is unnecessary: processed is a local variable used by only one thread. Atomics are for data shared between threads. Wrapping a thread-local counter in an atomic only makes each increment slower.


Practical example

Example 1: RAII pattern

#include <iostream>
#include <thread>
#include <stdexcept>
#include <chrono>

using namespace std::chrono_literals;

void riskyOperation() {
    std::jthread worker([] {
        for (int i = 0; i < 10; i++) {
std::cout << "task " << i << std::endl;
            std::this_thread::sleep_for(100ms);
        }
    });
    
    // Even if an exception occurs, workers are automatically joined.
    if (rand() % 2 == 0) {
throw std::runtime_error("An exception occurred!");
    }
    
std::cout << "normal end" << std::endl;
}

int main() {
    try {
        riskyOperation();
    } catch (const std::exception& e) {
std::cout << "Exception handling: " << e.what() << std::endl;
    }
    
std::cout << "main end" << std::endl;
}

When the exception is thrown, stack unwinding destroys worker, which requests a stop and joins. The program no longer terminates, which is the point of the example. But look at what the thread does: its lambda takes no stop_token, so the stop request has no effect, and the destructor waits until all 10 iterations (about one second) are finished before the exception can propagate. For short tasks that is acceptable. For a long-running task, an exception in the parent now means waiting for the task to complete. Give such tasks a stop_token parameter and check it, so the destructor’s stop request can actually end them early.

Example 2: Managing multiple threads

#include <iostream>
#include <thread>
#include <vector>
#include <chrono>

using namespace std::chrono_literals;

void workerTask(int id, std::stop_token stoken) {
    while (!stoken.stop_requested()) {
std::cout << "thread " << id << "working" << std::endl;
        std::this_thread::sleep_for(200ms);
    }
std::cout << "thread " << id << "end" << std::endl;
}

int main() {
    std::vector<std::jthread> threads;
    
// Create 5 threads
    for (int i = 0; i < 5; i++) {
        threads.emplace_back(workerTask, i);
    }
    
std::cout << "All threads running..." << std::endl;
    std::this_thread::sleep_for(2s);
    
std::cout << "Request to stop all threads" << std::endl;
    
// Request all threads to stop
    for (auto& t : threads) {
        t.request_stop();
    }
    
// Automatically join all threads when vector is destroyed
std::cout << "main end" << std::endl;
}

Example 3: Use with condition variables

#include <iostream>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <chrono>

using namespace std::chrono_literals;

std::mutex mtx;
std::condition_variable_any cv;
std::queue<int> taskQueue;

void worker(std::stop_token stoken) {
    while (true) {
        std::unique_lock<std::mutex> lock(mtx);
        
        // wait supporting stop_token
        if (cv.wait(lock, stoken, []{ return !taskQueue.empty(); })) {
            int task = taskQueue.front();
            taskQueue.pop();
            lock.unlock();
            
std::cout << "Process: " << task << std::endl;
            std::this_thread::sleep_for(100ms);
        }
        
        if (stoken.stop_requested()) {
std::cout << "Exit worker" << std::endl;
            break;
        }
    }
}

int main() {
    std::jthread t(worker);
    
    // Add task
    for (int i = 0; i < 10; i++) {
        {
            std::lock_guard<std::mutex> lock(mtx);
            taskQueue.push(i);
        }
        cv.notify_one();
        std::this_thread::sleep_for(50ms);
    }
    
    std::this_thread::sleep_for(2s);
    t.request_stop();
    cv.notify_one();  // Not needed: the stop request wakes the wait
}

This is the most useful pattern in the article. A normal std::condition_variable::wait blocks until notified, so a thread waiting for work that never comes cannot be stopped, and the jthread destructor hangs. std::condition_variable_any has overloads that take a stop_token: the wait returns as soon as a stop is requested, even without a notification, and returns the predicate’s value so you can tell whether there is work or the thread should exit. That is why cv is a condition_variable_any rather than a condition_variable. Only the _any variant has these overloads.

The final cv.notify_one() in main is therefore redundant, but harmless. Also note the ordering inside the worker: after being woken by a stop, taskQueue may still contain items. This worker exits without processing them. Whether a shutdown should drain the queue or drop pending work is a design decision that the code should make explicitly.

In my experience, the most common reason a program “hangs on exit” after switching to jthread is exactly this: a thread blocked in a wait, a socket recv, or a blocking read that knows nothing about stop tokens. The destructor requests a stop, nobody sees it, and join() waits forever. For condition variables, the _any overloads fix it. For I/O, you need a way to wake the blocked call, such as closing the socket from a stop_callback, using a timeout, or using an asynchronous I/O library that supports cancellation.


Advanced use of stop_token

stop_callback

#include <iostream>
#include <thread>
#include <chrono>

using namespace std::chrono_literals;

void worker(std::stop_token stoken) {
    // Register callback when requesting abort
    std::stop_callback callback(stoken, [] {
std::cout << "Abort callback called!" << std::endl;
    });
    
    int count = 0;
    while (!stoken.stop_requested()) {
std::cout << "task " << count++ << std::endl;
        std::this_thread::sleep_for(200ms);
    }
}

int main() {
    std::jthread t(worker);
    
    std::this_thread::sleep_for(1s);
t.request_stop();  // callback runs here, on the main thread
}

A stop_callback runs synchronously in the thread that calls request_stop(), here the main thread, not in the worker. If a stop was already requested when the callback is registered, it runs immediately in the registering thread instead. That makes callbacks the right tool for waking up a blocked operation from outside (closing a socket, cancelling a timer, notifying a condition variable), and the wrong tool for heavy cleanup: request_stop() does not return until all registered callbacks have finished, so a slow callback delays whoever requested the stop. Callbacks are unregistered when the stop_callback object is destroyed, so its lifetime should match the operation it can cancel.

stop_source

#include <iostream>
#include <thread>
#include <chrono>

using namespace std::chrono_literals;

void worker(std::stop_token stoken) {
    while (!stoken.stop_requested()) {
std::cout << "Working..." << std::endl;
        std::this_thread::sleep_for(200ms);
    }
}

int main() {
    std::stop_source ssource;
    std::stop_token stoken = ssource.get_token();
    
    std::jthread t(worker, stoken);
    
    std::this_thread::sleep_for(1s);
ssource.request_stop();  // Request to stop with stop_source
}

This example has a trap. worker takes one stop_token, and we pass stoken explicitly, so jthread cannot also prepend its own token (that would make two arguments). The thread therefore observes only the external ssource. The jthread’s destructor still calls request_stop() on its own stop source, which nobody is watching. If main forgot the ssource.request_stop() line, or returned early, the destructor would request a stop that has no effect, and join() would wait forever. An external stop source is useful for stopping many threads with one call, but then the code that owns it must always trigger it before the threads are destroyed.


Frequently occurring problems

Issue 1: Missing join (std::thread)

#include <thread>
#include <iostream>

// ❌ std::thread: terminate when join is missing
void badExample() {
    std::thread t([] {
std::cout << "task" << std::endl;
    });
    
    // Destroyed without join() or detach()
    // → call std::terminate!
}

// ✅ std::thread: explicit join
void goodExample1() {
    std::thread t([] {
std::cout << "task" << std::endl;
    });
    
    t.join();  // essential
}

// ✅ std::jthread: auto join
void goodExample2() {
    std::jthread t([] {
std::cout << "task" << std::endl;
    });
    
    // Automatic join in destructor
}

Issue 2: Missing break check

#include <thread>
#include <iostream>
#include <chrono>

using namespace std::chrono_literals;

// ❌ No interruption check (infinite loop)
void badWorker(std::stop_token stoken) {
    while (true) {
std::cout << "Working..." << std::endl;
        std::this_thread::sleep_for(100ms);
// no stop_requested() check!
    }
}

// ✅ Periodically check for interruptions
void goodWorker(std::stop_token stoken) {
    while (!stoken.stop_requested()) {
std::cout << "Working..." << std::endl;
        std::this_thread::sleep_for(100ms);
    }
std::cout << "normal end" << std::endl;
}

Problem 3: Using detach

#include <thread>
#include <iostream>

int main() {
    std::jthread t([] {
std::cout << "task" << std::endl;
    });
    
    // ❌ No automatic join after detachment
    t.detach();
    
    // Thread runs in background
    // When main terminates, threads may also be forcibly terminated.
}

detach() gives up everything jthread provides: no join, and the destructor’s stop request goes nowhere because the jthread object no longer owns the thread. A detached thread that still uses objects from the scope that created it (captured by reference, or globals being destroyed during program exit) reads freed memory. There are few good reasons to detach a thread in modern code.

Issue 4: Move Semantics

#include <thread>
#include <iostream>

int main() {
    std::jthread t1([] {
std::cout << "task" << std::endl;
    });
    
// ✅ Moveable
    std::jthread t2 = std::move(t1);
    
// t1 no longer owns a thread
    t1.request_stop();  // returns false: t1 has no stop state after the move
    
// t2 is valid
    t2.request_stop();
}

Calling request_stop() on a moved-from jthread is not undefined behavior. The moved-from object no longer has an associated stop state, so the call simply returns false, and t1.joinable() is false. Its destructor does nothing. Ownership of the thread, and of its stop source, moved to t2.


Practical example: Background task manager

#include <iostream>
#include <thread>
#include <vector>
#include <functional>
#include <chrono>

using namespace std::chrono_literals;

class TaskManager {
public:
    using Task = std::function<void(std::stop_token)>;
    
    void addTask(Task task) {
        threads.emplace_back(task);
    }
    
    void stopAll() {
std::cout << "Request to stop all operations" << std::endl;
        for (auto& t : threads) {
            t.request_stop();
        }
    }
    
    size_t activeCount() const {
        return threads.size();
    }
    
    ~TaskManager() {
std::cout << "TaskManager destruction (auto join)" << std::endl;
    }
    
private:
    std::vector<std::jthread> threads;
};

int main() {
    TaskManager manager;
    
// Task 1: Counter
    manager.addTask([](std::stop_token stoken) {
        int count = 0;
        while (!stoken.stop_requested()) {
std::cout << "Counter: " << count++ << std::endl;
            std::this_thread::sleep_for(300ms);
        }
    });
    
//Task 2: Monitor
    manager.addTask([](std::stop_token stoken) {
        while (!stoken.stop_requested()) {
std::cout << "Monitoring..." << std::endl;
            std::this_thread::sleep_for(500ms);
        }
    });
    
    // Task 3: Logger
    manager.addTask([](std::stop_token stoken) {
        while (!stoken.stop_requested()) {
std::cout << "Log record" << std::endl;
            std::this_thread::sleep_for(1s);
        }
    });
    
std::cout << "Active task: " << manager.activeCount() << "count" << std::endl;
    
    std::this_thread::sleep_for(3s);
    manager.stopAll();
    
    std::this_thread::sleep_for(1s);
std::cout << "main end" << std::endl;
}

The destructor order is worth tracing. ~TaskManager runs its body first (printing the message), then destroys its members, and destroying the vector destroys each jthread, which requests a stop and joins. So even without stopAll(), every task would be stopped and joined when manager goes out of scope. stopAll() is still useful: it requests all stops before any join begins, so the three threads wind down in parallel. Without it, each destructor requests a stop and then waits for that one thread, so the logger task, which sleeps for a full second per iteration, may make shutdown noticeably slower.

activeCount() returns the number of thread objects, not the number of running threads, since finished jthreads stay in the vector until the manager is destroyed. And addTask takes a std::function by value and copies it into the thread. threads.emplace_back(std::move(task)) avoids the copy.


Moving from std::thread to std::jthread

Featuresstd::threadstd::jthread
joinmanual (join())auto (destructor)
interruptionNonestop_token
exception safetylowHigh (RAII)
Ease of useNormalHigh
C++ versionC++11C++20

Replacing std::thread with std::jthread is usually a one-line change, and existing join() calls keep working. That change alone only fixes the crash from a thread that was never joined. The real benefit comes from giving the thread function a std::stop_token parameter and checking stop_requested() at points in long loops, so the stop request the destructor sends can actually end the thread.

The part to audit is blocking calls. A thread sleeping in std::this_thread::sleep_for, waiting on a plain std::condition_variable, or blocked in a socket read does not notice a stop request, and the auto-joining destructor then waits for it indefinitely. Use std::condition_variable_any with the stop token overload of wait, register a std::stop_callback that wakes or closes whatever the thread is blocked on, or split long sleeps into short waits that check the token.

Next steps