std::thread, std::jthread & Thread Management
Learn to create and manage threads with std::thread and C++20's std::jthread. Understand join vs detach, cooperative cancellation with stop tokens, thread-local storage, and exception handling across thread boundaries.
Why Concurrency?
Modern CPUs have multiple cores, and to exploit them your program must run work concurrently. C++11 introduced a portable threading library in , and C++20 improved it with std::jthread. A thread is an independent path of execution that shares the same address space as the thread that created it — which is both its power and its danger.
Unlike processes, threads share memory. That makes communication fast but also introduces data races if two threads read and write the same variable without synchronization. Before we tackle synchronization (next lesson), let us first learn how to create, manage, and cleanly shut down threads.
Creating Threads with std::thread
You create a std::thread by passing it a callable (function, lambda, functor) and any arguments. The thread begins executing immediately upon construction.
#include <iostream>
#include <thread>
void greet(const std::string& name, int times) {
for (int i = 0; i < times; ++i) {
std::cout << "Hello, " << name << "! (" << i + 1 << ")\n";
}
}
int main() {
// Launch a thread running greet("Alice", 3)
std::thread t1(greet, "Alice", 3);
// Lambda-based thread
std::thread t2([]() {
std::cout << "Running in a lambda thread\n";
});
// MUST join or detach before t1/t2 are destroyed
t1.join(); // block until t1 finishes
t2.join();
std::cout << "All threads finished\n";
return 0;
}join() vs detach()
Every std::thread that represents a running thread must be either joined or detached before its destructor runs. If you forget, std::terminate() is called and your program crashes.
join() blocks the calling thread until the target thread finishes. This is the safe default — you know the thread is done and its results are ready.
detach() severs the connection between the std::thread object and the OS thread. The thread runs in the background (a "daemon thread") and you can no longer join it. Be cautious: if the detached thread accesses stack variables from the spawning function and that function returns, you get undefined behavior.
Never detach() a thread that captures or references local variables from the creating scope. When the creating function returns, those locals are destroyed and the detached thread accesses dangling memory. This is undefined behavior and often manifests as sporadic crashes. If you must detach, pass data by value or use std::shared_ptr to extend lifetimes.
std::jthread — The RAII Thread (C++20)
std::jthread solves two problems with std::thread: (1) it automatically joins in its destructor, so you can never forget, and (2) it supports cooperative cancellation via std::stop_token.
#include <iostream>
#include <thread>
#include <chrono>
void worker(std::stop_token stoken, int id) {
while (!stoken.stop_requested()) {
std::cout << "Worker " << id << " running...\n";
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}
std::cout << "Worker " << id << " stopping gracefully\n";
}
int main() {
std::jthread jt1(worker, 1); // stop_token is auto-passed as first arg
std::jthread jt2(worker, 2);
std::this_thread::sleep_for(std::chrono::seconds(1));
// Request both threads to stop
jt1.request_stop();
jt2.request_stop();
// No need to call join() — jthread's destructor does it
std::cout << "Main thread exiting\n";
return 0;
}Querying Hardware & Thread-Local Storage
std::thread::hardware_concurrency() returns the number of concurrent threads the hardware supports. The thread_local storage duration gives each thread its own copy of a variable.
#include <iostream>
#include <thread>
#include <vector>
thread_local int tls_counter = 0; // each thread gets its own copy
void count_work(int id) {
for (int i = 0; i < 1000; ++i) {
++tls_counter; // no data race — each thread has its own
}
std::cout << "Thread " << id << ": tls_counter = " << tls_counter << "\n";
}
int main() {
unsigned int n = std::thread::hardware_concurrency();
std::cout << "Hardware concurrency: " << n << " threads\n";
std::vector<std::thread> threads;
for (unsigned int i = 0; i < n; ++i) {
threads.emplace_back(count_work, i);
}
for (auto& t : threads) {
t.join();
}
// Each thread printed 1000 — they don't share tls_counter
return 0;
}In C++20 and later, prefer std::jthread over std::thread. It automatically joins in its destructor, eliminating the risk of calling std::terminate if you forget to join. Its built-in std::stop_token mechanism provides a clean, standardized way to request cooperative cancellation — no need to roll your own atomic flag.
Exception Handling Across Threads
An exception thrown inside a thread that is not caught within that thread calls std::terminate(). Exceptions do not propagate automatically to the joining thread.
To transfer exceptions between threads, capture them with std::current_exception() inside the worker thread and store them in a std::exception_ptr. The joining thread can then rethrow with std::rethrow_exception(). The std::future mechanism (covered in Lesson 4) handles this automatically.
std::threadstarts executing immediately; you mustjoin()ordetach()before its destructor runsstd::jthread(C++20) auto-joins in its destructor and supports cooperative cancellation viastd::stop_tokenthread_localgives each thread its own independent copy of a variable — no synchronization neededstd::thread::hardware_concurrency()tells you how many threads the hardware can run concurrently- Exceptions in a thread do not propagate to the caller — use
std::exception_ptrorstd::futureto transfer them - Never let a detached thread reference local variables from the spawning scope
Quiz — Test Your Knowledge
(15 XP)1. What happens if a `std::thread` object representing a joinable thread is destroyed without calling `join()` or `detach()`?
2. How does `std::jthread` support cooperative cancellation?
3. What does the `thread_local` keyword do?