This series is about concurrency in C++: running code on multiple threads, protecting the data they share, signaling between them, and the ordering rules that govern it all. The map below is the full territory. This post covers the THREADS column — what threads are for, and how to use them in idiomatic C++.
C++ CONCURRENCY
|
+-------------+------------+------------+--------------+
| | | | |
THREADS SHARING WAITING TASKS ORDERING
(running) (protecting) (signaling) (returning) (the rules)
| | | | |
std::thread mutex + condition std::async atomics,
std::jthread lock_guard/ variables, futures / happens-before,
unique_lock/ latch, promises, acquire/release,
scoped_lock barrier, packaged_task seq_cst
semaphore
Motivation
Threads let a program do several things at once. A simple example:
#include <iostream>
#include <thread>
int x = 0;
int y = 0;
void f() { x = 40; }
void g() { y = 2; }
int main() {
std::thread a{f};
std::thread b{g};
a.join();
b.join();
std::cout << x + y << "\n"; // 42
}
Two threads run at the same time — one computes x, the other y.
std::thread a{f} creates a new thread inside the same process and sets it running on f immediately. The constructor returns without waiting, so main continues to the next line while f runs alongside it.
a.join() blocks the caller until f has returned. Only the caller waits: while main sits in a.join(), b keeps running, so the join order doesn’t have to match the finish order. After a join returns, everything the thread wrote is visible to the joiner — which is what makes reading x and y afterwards safe.
The alternative is a.detach(), which disowns the thread: it keeps running on its own, and there is no way to wait for it or get anything back. Useful for fire-and-forget work, rarely what you want:
std::thread t{background_logger};
t.detach(); // runs on its own; t no longer refers to it
Every std::thread must be joined or detached before the object is destroyed.
std::jthread
std::thread’s destructor doesn’t join — it calls std::terminate if the thread is still joinable. So every exit path has to be covered by hand:
void risky() {
std::thread t{work};
if (failed()) return; // t destroyed while joinable -> std::terminate(), process dies
t.join();
}
An exception thrown before the join() does the same thing. std::jthread (C++20) fixes this by joining in its destructor:
void safe() {
std::jthread t{work};
if (failed()) return; // fine, destructor calls t.join()
} // fine, destructor calls t.join()
Same RAII pattern as a lock_guard: correct on every exit path, including exceptions.
jthread also carries a stop flag. There’s no way to kill a thread in C++ — that would leave locks held and destructors unrun — so stopping is cooperative: you ask, and the thread returns on its own. If the callable’s first parameter is a std::stop_token, jthread passes one in automatically:
void worker(std::stop_token st) { // first param is a stop_token,
// so jthread passes one in
while (!st.stop_requested()) { // check the flag each round
std::this_thread::sleep_for(100ms);
}
printf("stopped\n");
}
int main() {
{
std::jthread t{worker};
std::this_thread::sleep_for(350ms);
printf("leaving scope\n");
} // destructor: request_stop(), then join()
printf("done\n");
}
leaving scope
stopped
done
The destructor requests the stop first, then joins — without the request, that join would hang forever on an infinite loop. The closing brace blocks: main waits there until the worker notices and returns.
The flag is polled, not preemptive, so a thread parked in a long sleep_for won’t notice until it wakes. t.request_stop() also works explicitly, and std::stop_source lets one signal cancel several workers.
Prefer jthread by default. std::thread is for when you specifically want detach semantics.
The std::this_thread namespace acts on the current thread — sleep_for above, plus get_id() and yield().
thread_local
thread_local is a storage duration, alongside the familiar three:
automatic (locals) one per scope entry dies at closing brace
static (globals) one, program-wide dies at program exit
dynamic (new/delete) as many as you make dies when you say
thread (thread_local) one per thread dies when that thread exits
A thread_local variable looks like a global in the source, but every thread gets its own copy:
thread_local int perthread = 0;
void work(const char* name) {
for (int i = 0; i < 1000; ++i)
++perthread; // no race: private copy
printf("%s: perthread=%d (address %p)\n", name, perthread, (void*)&perthread);
}
thread A: perthread=1000 (address 0x102485edc)
thread B: perthread=1000 (address 0x10248605c)
Both threads increment “the same variable” a thousand times and both see 1000, not 2000 — different addresses, different objects. Each copy is initialized before its first use in that thread and destroyed when that thread exits, destructors included.
The point is to sidestep sharing rather than protect it: no sharing, no race, no mutex. errno works this way, and it’s the usual fix for per-thread random engines, scratch buffers, and logging context.
The costs: access goes through an indirection rather than a fixed address, and memory multiplies by thread count. The trap is thread pools — a thread_local lives as long as the thread, not the task, so state left behind by one task is still there when the next one runs on that thread.
Wrapping Up
Threads run alongside each other in one process, sharing everything but their stacks. std::thread launches one and must be joined or detached before it’s destroyed; std::jthread does the joining in its destructor and carries a stop flag for asking a thread to finish. thread_local gives each thread a private copy of a variable, which avoids sharing altogether.
Everything here worked because the threads never touched the same data. Pt 2 covers what happens when they do, and the tools for it: SHARING.
Appendix: Threads, Processes, and pthread_create
Expand
A process owns resources: a virtual address space, a heap, file descriptors, the loaded code. A thread is an execution context within a process: its own stack, registers, and program counter. Everything else is shared:
thread A thread B
own stack own stack
own registers + PC own registers + PC
\ /
SHARED: heap, globals, code, file descriptors
This is why the threads in the example above could just write to the globals x and y: same address space, no copying, no message-passing. A new process (fork) would have been useless there — the child writes to its own copy of x, invisible to the parent.
pthread_create is the POSIX threads C API — the layer std::thread is a thin wrapper over on Linux and macOS. Constructing a std::thread walks this chain:
std::thread a{f};
|
| 1. library: copy f (and any args) into storage owned
| by the new thread
| 2. call pthread_create(...)
| 3. syscall: kernel creates a new schedulable entity
| - fresh stack (~512 KB on macOS, 8 MB on Linux, by default)
| - fresh registers + program counter, aimed at f
| - added to the scheduler's run queue
| 4. the returned handle is stored in the std::thread object
v
constructor returns — possibly before f has even started
From then on the kernel time-slices all threads across cores like anything else it schedules. Note the last line: constructing a thread only guarantees f will run, not that it has started. The only ordering guarantee is at join().