C++ Module 16 – Concurrency: threads, atomics and the memory model
Threads with std::thread and std::jthread
Start C++ threads with std::thread and std::jthread, pass arguments safely, wait with join(), and stop a worker cooperatively with std::stop_token.
What you will learn
- Start threads with std::thread and wait for them with join()
- Split a job across threads that each write only their own results
- Explain why destroying a joinable std::thread ends the program
- Pass arguments to a thread by value, with std::ref or by moving them
- Cancel a std::jthread cooperatively through its std::stop_token
Before you start
What you’ll learn · Before you start
What you will learn
- Start threads with std::thread and wait for them with join()
- Split a job across threads that each write only their own results
- Explain why destroying a joinable std::thread ends the program
- Pass arguments to a thread by value, with std::ref or by moving them
- Cancel a std::jthread cooperatively through its std::stop_token
Before you start
On this page
A thread is a separate line of execution inside your program: it runs a function at the same time as main and
any other threads, and it shares the program’s memory with them. In C++, std::thread starts one. Writing
std::thread t(f, x) begins running f(x) at once, and t.join() waits until that call has returned.
std::jthread, new in C++20, does the same, but it joins by itself when it goes out of scope, and it can ask its
thread to stop through a std::stop_token. Prefer std::jthread in new code; std::thread is what older code and
C++17 compilers have. This lesson splits a sum across four threads, shows what happens when a thread is never joined,
passes arguments without dangling references, stops a worker cleanly, and measures how much 18 cores speed a job up.
Your first threads: one sum in four parts
The program below adds up the numbers 1 to 10,000,000. Instead of one loop, it starts four threads, and each one sums a quarter of the vector:
// Adds up the numbers 1 to 10,000,000 with four threads. Each thread sums one quarter of the vector into its own
// element of `partial`, so no two threads ever write the same object; main adds the four results after join().
#include <cstddef>
#include <cstdint>
#include <numeric>
#include <print>
#include <thread>
#include <vector>
int main() {
std::vector<std::int64_t> numbers(10'000'000);
std::iota(numbers.begin(), numbers.end(), 1); // 1, 2, 3, ..., 10,000,000
constexpr std::size_t n_threads = 4;
std::vector<std::int64_t> partial(n_threads); // one result slot per thread
std::vector<std::thread> threads;
for (std::size_t t = 0; t < n_threads; ++t) {
const std::size_t first = numbers.size() * t / n_threads;
const std::size_t last = numbers.size() * (t + 1) / n_threads;
// numbers and partial are captured by reference: both outlive the threads, because main joins them below.
threads.emplace_back([&numbers, &partial, t, first, last] {
std::int64_t sum = 0;
for (std::size_t i = first; i < last; ++i) sum += numbers[i];
partial[t] = sum; // the only write, to this thread's own slot
});
}
for (std::thread& th : threads) th.join(); // wait until all four have finished
for (std::size_t t = 0; t < n_threads; ++t) std::println("thread {} summed {}", t, partial[t]);
const std::int64_t total = std::accumulate(partial.begin(), partial.end(), std::int64_t{0});
std::println("total {}", total);
std::println("formula {}", std::int64_t{10'000'000} * 10'000'001 / 2); // n(n + 1) / 2
} One job split across four threads, then joined
Text description of the diagram
The diagram reads from top to bottom.
- At the top, main fills the vector numbers with 1 to 10,000,000.
- An arrow labelled "start 4 threads" leads to a box of four threads that run at the same time, two per row. Thread 0 sums the first quarter of the vector and writes partial[0]; thread 1 sums the second quarter and writes partial[1]; thread 2 the third quarter, into partial[2]; thread 3 the fourth quarter, into partial[3].
- An arrow labelled "each join() waits for one thread" leads to the bottom box: main joins each thread and then adds partial[0] to partial[3].
Built with each compiler, it prints the same four partial sums and the same total, which matches the formula n(n + 1) / 2:
from session import run
# sum_threads.cpp built and run with Apple clang, then with GCC 16.
run("""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined sum_threads.cpp -o sum_threads && ./sum_threads
g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined sum_threads.cpp -o sum_threads-gcc && ./sum_threads-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined sum_threads.cpp -o sum_threads && ./sum_threads thread 0 summed 3125001250000 thread 1 summed 9375001250000 thread 2 summed 15625001250000 thread 3 summed 21875001250000 total 50000005000000 formula 50000005000000 $ g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined sum_threads.cpp -o sum_threads-gcc && ./sum_threads-gcc thread 0 summed 3125001250000 thread 1 summed 9375001250000 thread 2 summed 15625001250000 thread 3 summed 21875001250000 total 50000005000000 formula 50000005000000
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 sum_session.py
Three things make this program correct:
- The threads never share a write. Each thread writes one element of
partial, its own, and only readsnumbers. Two threads that read the same object at the same time are fine; two that touch the same object while one of them writes need protection, which is the subject of the next lesson. join()comes before the results are read.join()waits until the thread has finished, and everything the thread wrote is then visible to the thread that joined it: the standard says the thread’s completion synchronizes with the return fromjoin(). Readingpartialbefore the joins would read values that may not be there yet.- The references stay valid. The lambda captures
numbersandpartialby reference, which is safe only because both outlive every thread:mainjoins all four before either vector is destroyed.
The threads start running as soon as each std::thread is constructed, and the operating system decides when each
one gets a processor. The output is still in a fixed order, because main prints only after all four have finished.
A joinable thread must not be destroyed
A std::thread that represents a running (or finished but not yet joined) thread is joinable. Before the object
is destroyed, you must either join() it, which waits, or detach() it, which lets the thread run on with nobody
waiting for it. Forget both, and the destructor calls std::terminate(), which ends the whole program:
// A std::thread that is still joinable when it is destroyed ends the whole program.
#include <chrono>
#include <print>
#include <thread>
int main() {
std::thread worker([] {
std::this_thread::sleep_for(std::chrono::seconds(1)); // still running when main ends
std::println("worker: done");
});
std::println("main: leaving without join() or detach()");
} // ~thread() finds worker joinable and calls std::terminate() from session import run
# forgot_join.cpp ends in std::terminate() with each compiler's library: libc++, then libstdc++.
run("""
clang++ -std=c++23 -Wall -Wextra forgot_join.cpp -o forgot_join && ./forgot_join
g++-16 -std=c++23 -Wall -Wextra forgot_join.cpp -o forgot_join-gcc && ./forgot_join-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra forgot_join.cpp -o forgot_join && ./forgot_join main: leaving without join() or detach() libc++abi: terminating Abort trap: 6 [exit status 134] $ g++-16 -std=c++23 -Wall -Wextra forgot_join.cpp -o forgot_join-gcc && ./forgot_join-gcc main: leaving without join() or detach() terminate called without an active exception Abort trap: 6 [exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 terminate_session.py
Each library says it in its own words, “libc++abi: terminating” and “terminate called without an active exception”,
and both end with abort(), which the shell reports as exit status 134. The worker never printed its line: the
program ended while it slept. The standard chose to terminate on purpose. Its note on the destructor explains that
an automatic detach() would cause correctness bugs and an automatic join() performance bugs, and both would show
up only when an exception skips the code that was meant to join.
That last point is the everyday danger. Any code between starting a thread and joining it can throw:
- if
std::thread t(work); parse(input); t.join();andparsethrows,tis destroyed while still joinable, and the exception ends the program instead of reaching its handler; - wrapping the code in
tryand callingjoin()in every path works, but it is easy to forget one path.
The C++ Core Guidelines (CP.25) recommend a thread type that joins in its destructor, and C++20 added one to the
standard library: std::jthread.
Why detach() is rarely the answer
detach() does avoid std::terminate(), but it trades one problem for others. Nobody can wait for a detached
thread, so nobody knows when it is finished. When main returns, the process ends and every detached thread stops
wherever it is, even in the middle of writing a file. And a detached thread that refers to a local variable can easily
outlive that variable:
// A detached thread that keeps a reference to a local variable outlives that variable.
#include <chrono>
#include <print>
#include <string>
#include <thread>
using namespace std::chrono_literals;
void start_logger() {
std::string channel = "orders";
std::thread logger([&channel] { // captures a reference to start_logger()'s local string
std::this_thread::sleep_for(100ms);
std::println("logging to {}", channel); // by now start_logger() has returned
});
logger.detach(); // nobody waits for it
} // channel is destroyed here
int main() {
start_logger();
std::this_thread::sleep_for(3s); // keep the process alive long enough for the logger to run
} The logger thread captures channel by reference, start_logger() returns, and 100 milliseconds later the thread
reads a string that no longer exists. That is undefined behaviour: a normal build may print the right word, garbage
or nothing. AddressSanitizer catches it when it watches for stack memory used after its function has returned.
Clang’s documentation says that check is on by default only on Linux; -fsanitize-address-use-after-return=always
turns it on everywhere:
from session import run
# dangling_detach.cpp under AddressSanitizer, with detection of stack memory used after its function returned.
run("""
clang++ -std=c++23 -g -fsanitize=address -fsanitize-address-use-after-return=always dangling_detach.cpp -o dangling_detach
./dangling_detach
""") Output
$ clang++ -std=c++23 -g -fsanitize=address -fsanitize-address-use-after-return=always dangling_detach.cpp -o dangling_detach
$ ./dangling_detach
==…==ERROR: AddressSanitizer: stack-use-after-return on address 0x…
READ of size 1 at 0x… thread T1
#11 start_logger()::$_0::operator()() const dangling_detach.cpp:13
Address 0x… is located in stack of thread T0 at offset 55 in frame
#0 start_logger() dangling_detach.cpp:9
This frame has 3 object(s):
[32, 56) 'channel' (line 10) <== Memory access at offset 55 is inside this variable
[96, 104) 'logger' (line 11)
[128, 136) 'ref.tmp' (line 11)
SUMMARY: AddressSanitizer: stack-use-after-return dangling_detach.cpp:13:9 in start_logger()::$_0::operator()() const
Thread T1 created by T0 here:
#4 start_logger() dangling_detach.cpp:11
#5 main dangling_detach.cpp:19
==…==ABORTING
[58 more lines of the report left out]
[exit status 134]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 detach_session.py
The report names the variable, 'channel' (line 10), the frame it lived in, start_logger(), and the thread that
read it, T1, created at line 11. The sanitizer then stops the program with exit status 134. The Core Guidelines put
the lesson shortly (CP.26): don’t detach() a thread.
std::jthread joins for you, and can be asked to stop
std::jthread is a std::thread with two additions. Its destructor first asks the thread to stop, then joins it.
And if the function you give it takes a std::stop_token as its first parameter, the jthread passes its own token,
which the function checks to see whether a stop has been requested:
// std::jthread joins in its destructor, after asking its thread to stop through the thread's std::stop_token.
#include <chrono>
#include <print>
#include <stop_token>
#include <thread>
using namespace std::chrono_literals;
int main() {
{
// A callable whose first parameter is a std::stop_token receives the jthread's token.
std::jthread worker([](std::stop_token token) {
while (!token.stop_requested()) {
std::this_thread::sleep_for(10ms); // one round of work
}
std::println("worker: stop requested, finishing");
});
std::this_thread::sleep_for(100ms);
std::println("main: leaving the block");
} // ~jthread(): request_stop(), then join()
std::println("main: the worker has finished");
std::println("sizeof(std::thread) = {}, sizeof(std::jthread) = {}", sizeof(std::thread), sizeof(std::jthread));
} At the closing brace, the destructor of worker requests a stop and waits. The worker sees the request on its next
check, prints its line and returns, and only then does main go on. The two types compare like this:
| Criterion | std::thread | std::jthread |
|---|---|---|
| Standard and header | C++11, <thread> | C++20, <thread> (stop tokens in <stop_token>) |
| Destroyed while joinable | Calls std::terminate() | Calls request_stop(), then join() |
| Move-assigned over a joinable thread | Calls std::terminate() | Requests a stop and joins the old thread first |
| Asking the thread to stop | Your own flag, shared with the thread | request_stop() and the std::stop_token it passes |
| Size on the computer that ran the examples | 8 bytes | 16 bytes, plus a stop state allocated when it starts a thread |
| When to choose | Code that must still build as C++17 | Everything else, including threads you would detach |
Stopping a thread cooperatively
C++ has no way to kill a thread from the outside, and that is deliberate: a thread stopped at an arbitrary point could be holding a lock or be halfway through updating a data structure, and the rest of the program would inherit the mess. A stop in C++ is a request. The thread decides where it is safe to stop by checking its token:
// Asking a std::jthread to stop: request_stop(), the token's stop_requested() and a std::stop_callback.
#include <chrono>
#include <print>
#include <stop_token>
#include <thread>
using namespace std::chrono_literals;
int main() {
const std::thread::id main_id = std::this_thread::get_id();
std::jthread worker([main_id](std::stop_token token) {
// Runs once, when a stop is requested, in the thread that requests it.
std::stop_callback on_stop(token, [main_id] {
std::println("callback: ran on the main thread: {}", std::this_thread::get_id() == main_id);
});
int rounds = 0;
while (!token.stop_requested()) {
++rounds;
std::this_thread::sleep_for(5ms);
}
std::println("worker: saw the request after at least one round: {}", rounds >= 1);
});
std::this_thread::sleep_for(100ms);
const bool first = worker.request_stop(); // true: this call made the request
const bool second = worker.request_stop(); // false: a stop was already requested
worker.join();
std::println("main: request_stop() returned {}, then {}", first, second);
std::println("main: joinable() after join(): {}", worker.joinable());
} from session import run
# jthread_scope.cpp and stop_token.cpp, each with both compilers.
run("""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined jthread_scope.cpp -o jthread_scope && ./jthread_scope
g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined jthread_scope.cpp -o jthread_scope-gcc && ./jthread_scope-gcc
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined stop_token.cpp -o stop_token && ./stop_token
g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined stop_token.cpp -o stop_token-gcc && ./stop_token-gcc
""") Output
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined jthread_scope.cpp -o jthread_scope && ./jthread_scope main: leaving the block worker: stop requested, finishing main: the worker has finished sizeof(std::thread) = 8, sizeof(std::jthread) = 16 $ g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined jthread_scope.cpp -o jthread_scope-gcc && ./jthread_scope-gcc main: leaving the block worker: stop requested, finishing main: the worker has finished sizeof(std::thread) = 8, sizeof(std::jthread) = 16 $ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined stop_token.cpp -o stop_token && ./stop_token callback: ran on the main thread: true worker: saw the request after at least one round: true main: request_stop() returned true, then false main: joinable() after join(): false $ g++-16 -std=c++23 -Wall -Wextra -fsanitize=address,undefined stop_token.cpp -o stop_token-gcc && ./stop_token-gcc callback: ran on the main thread: true worker: saw the request after at least one round: true main: request_stop() returned true, then false main: joinable() after join(): false
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stop_session.py
The run shows the three pieces of the stop machinery:
request_stop()returnstruewhen this call made the request andfalsewhen a stop had already been requested, so exactly one caller “wins”.stop_requested()on the token becomestrueand stays true. The worker checks it once per round, so it stops within one round, here at most 5 milliseconds later.- A
std::stop_callbackregistered on the token runs when the stop is requested, in the thread that requests it, beforerequest_stop()returns. That is why the callback reported that it ran on the main thread. If the stop had already been requested when the callback was registered, it would run at once, in the registering thread.
A token cannot interrupt a thread that is blocked. sleep_for finishes its full time, and a thread waiting to read
from a socket waits until data arrives. Keep each round of work short, use timeouts, or use a std::stop_callback to
wake the thread up, for example by closing the socket. Condition variables have a wait that also returns on a stop
request, which a later lesson of this module uses.
Passing arguments: copies unless you say otherwise
std::thread t(f, a, b) does not hand a and b to the new thread as they are. The constructor first copies or
moves each argument into storage that the new thread owns, in the thread that calls the constructor, and the new
thread then calls f with those copies. Three cases follow from that rule:
// How std::thread passes arguments: it copies (or moves) each one into storage of its own before the thread starts.
#include <memory>
#include <print>
#include <string>
#include <thread>
void add_one(int& n) { ++n; }
void show(const std::string& s) { std::println("show() sees \"{}\"", s); }
void consume(std::unique_ptr<std::string> p) { std::println("consume() owns \"{}\"", *p); }
int main() {
// 1. A copy: the thread gets its own std::string, made in main before the constructor returns.
std::string text = "first";
std::thread a(show, text);
text = "changed in main"; // changes main's string, not the thread's copy
a.join();
// 2. A reference, on purpose: std::ref wraps n so that add_one() can bind its int& to it.
int n = 41;
std::thread b(add_one, std::ref(n));
b.join(); // n must live at least until here
std::println("after add_one: n = {}", n);
// 3. A move-only argument is moved in: the thread's function becomes its owner.
auto owned = std::make_unique<std::string>("a moved string");
std::thread c(consume, std::move(owned));
c.join();
std::println("main's unique_ptr is now {}", owned ? "still set" : "empty");
} // add_one() takes int&, but std::thread passes its stored copy of n as an rvalue, which int& cannot bind to.
#include <thread>
void add_one(int& n) { ++n; }
int main() {
int n = 41;
std::thread t(add_one, n); // does not compile: write std::ref(n)
t.join();
} from session import run
# arguments.cpp with both compilers, then the error each one gives for needs_ref.cpp.
run("""
clang++ -std=c++23 -Wall -Wextra arguments.cpp -o arguments && ./arguments
g++-16 -std=c++23 -Wall -Wextra arguments.cpp -o arguments-gcc && ./arguments-gcc
clang++ -std=c++23 -fsyntax-only needs_ref.cpp 2>&1 | grep -E "error:" | head -2
g++-16 -std=c++23 -fsyntax-only needs_ref.cpp 2>&1 | grep -E "error:" | head -2
""") Output
$ clang++ -std=c++23 -Wall -Wextra arguments.cpp -o arguments && ./arguments show() sees "first" after add_one: n = 42 consume() owns "a moved string" main's unique_ptr is now empty $ g++-16 -std=c++23 -Wall -Wextra arguments.cpp -o arguments-gcc && ./arguments-gcc show() sees "first" after add_one: n = 42 consume() owns "a moved string" main's unique_ptr is now empty $ clang++ -std=c++23 -fsyntax-only needs_ref.cpp 2>&1 | grep -E "error:" | head -2 …/c++/v1/__thread/thread.h:215:19: error: static assertion failed due to requirement '__is_invocable_v<void (*)(int &), int>': …/c++/v1/__thread/thread.h:161:3: error: no matching function for call to '__invoke' $ g++-16 -std=c++23 -fsyntax-only needs_ref.cpp 2>&1 | grep -E "error:" | head -2 …/c++/16/bits/std_thread.h:168:72: error: static assertion failed: std::thread arguments must be invocable after conversion to rvalues …/c++/16/bits/std_thread.h:302:11: error: no type named 'type' in 'struct std::thread::_Invoker<std::tuple<void (*)(int&), int> >::__result<std::tuple<void (*)(int&), int> >'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 args_session.py
- A copy by default.
show()saw “first”, because the thread holds its own copy oftext, made before the constructor returned; changingtextinmainafterwards touches onlymain’s string. - A reference only on request.
add_one(int&)cannot take the thread’s stored copy, which is passed as an rvalue, soneeds_ref.cppdoes not compile; both libraries reject it with a static assertion, and libstdc++’s message, “std::thread arguments must be invocable after conversion to rvalues”, says why.std::ref(n)passes a reference instead, and the referenced object must then outlive the thread. - Move-only types are moved. A
std::unique_ptrcannot be copied, sostd::movehands its ownership to the thread’s function, andmain’s pointer is empty afterwards.
One trap follows from “the function is called in the new thread”: a conversion between the argument’s type and the
parameter’s type happens there too. Passing a char* to a local buffer to a function that takes std::string copies
the pointer, and the new thread builds the string from the buffer later, possibly after the buffer is gone. Convert
in the caller, std::thread t(f, std::string(buffer)), and the thread receives a complete string. The same thinking
applies to lambdas: [=] copies what it captures, [&] refers to it. The Core Guidelines advise passing small amounts
of data to threads by value (CP.31): a thread that owns its copies cannot be left holding a reference to something
that has gone.
How many threads?
std::thread::hardware_concurrency() returns the number of threads the hardware can run at the same time. The
standard calls it a hint, and an implementation returns 0 when it cannot tell. The program below counts the primes
below 10,000,000 with 1 to 36 threads. For each count it prints two times, keeping the fastest of three runs: the
wall time, what a clock on the wall would show, and the CPU time, the processor time all its threads used
together. CPU time divided by wall time is the number of cores the program kept busy, on average:
// Counts the primes below 10,000,000 by trial division with 1, 2, 4, 6, 12, 18 and 36 threads. For each run it
// prints the wall-clock time and the processor time all threads used together; their ratio is the number of cores
// the program kept busy on average. The numbers are dealt out in chunks of 10,000, round robin, so every thread gets
// a similar mix of small and large numbers, and each std::jthread writes its count to its own slot. Each run is
// repeated three times and the fastest is kept, because other programs on the computer slow some runs down.
#include <sys/resource.h>
#include <algorithm>
#include <chrono>
#include <cstdint>
#include <print>
#include <thread>
#include <vector>
bool is_prime(std::uint32_t n) {
if (n < 2) return false;
if (n % 2 == 0) return n == 2;
for (std::uint32_t d = 3; d * d <= n; d += 2)
if (n % d == 0) return false;
return true;
}
constexpr std::uint32_t limit = 10'000'000;
constexpr std::uint32_t chunk = 10'000;
std::uint32_t count_primes(unsigned n_threads) {
std::vector<std::uint32_t> counts(n_threads);
{
std::vector<std::jthread> workers;
for (unsigned t = 0; t < n_threads; ++t) {
workers.emplace_back([&counts, t, n_threads] {
std::uint32_t c = 0;
for (std::uint32_t first = t * chunk; first < limit; first += n_threads * chunk)
for (std::uint32_t n = first; n < std::min(first + chunk, limit); ++n) c += is_prime(n);
counts[t] = c;
});
}
} // every jthread joins here
std::uint32_t total = 0;
for (std::uint32_t c : counts) total += c;
return total;
}
// Processor time used so far by all threads of this process (user plus system), in milliseconds (POSIX getrusage).
double cpu_ms() {
rusage u{};
getrusage(RUSAGE_SELF, &u);
auto ms = [](timeval t) { return t.tv_sec * 1000.0 + t.tv_usec / 1000.0; };
return ms(u.ru_utime) + ms(u.ru_stime);
}
int main() {
std::println("hardware_concurrency() = {}", std::thread::hardware_concurrency());
std::println("threads primes wall time CPU time cores busy");
for (unsigned n : {1u, 2u, 4u, 6u, 12u, 18u, 36u}) {
double best_wall = 1e300, best_cpu = 0;
std::uint32_t primes = 0;
for (int run = 0; run < 3; ++run) {
const double cpu0 = cpu_ms();
const auto start = std::chrono::steady_clock::now();
primes = count_primes(n);
const double wall = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - start).count();
if (wall < best_wall) best_wall = wall, best_cpu = cpu_ms() - cpu0;
}
std::println("{:7} {:6} {:6.0f} ms {:5.0f} ms {:10.1f}", n, primes, best_wall, best_cpu, best_cpu / best_wall);
}
} from session import run
# The computer's processor and its two kinds of cores, then speedup.cpp built with optimisation and run once.
run("""
sysctl -n machdep.cpu.brand_string hw.perflevel0.logicalcpu hw.perflevel1.logicalcpu
clang++ -std=c++23 -O2 -Wall -Wextra speedup.cpp -o speedup && ./speedup
""") Output
$ sysctl -n machdep.cpu.brand_string hw.perflevel0.logicalcpu hw.perflevel1.logicalcpu
Apple M5 Pro
6
12
$ clang++ -std=c++23 -O2 -Wall -Wextra speedup.cpp -o speedup && ./speedup
hardware_concurrency() = 18
threads primes wall time CPU time cores busy
1 664579 1665 ms 936 ms 0.6
2 664579 823 ms 932 ms 1.1
4 664579 338 ms 927 ms 2.7
6 664579 264 ms 836 ms 3.2
12 664579 84 ms 852 ms 10.1
18 664579 76 ms 858 ms 11.2
36 664579 74 ms 878 ms 11.9
This output changes from run to run: the times change from run to run, more so while other programs keep the computer busy
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 speedup_session.py
The computer that ran the examples reported 18 hardware threads: an Apple M5 Pro with two kinds of cores, 6 in its
first performance level and 12 in its second, as sysctl lists them. It was also running other programs at the
time, as a shared build machine does, and the numbers show what threads can and cannot do:
- The work stays the same; only the waiting shrinks. Every run found the same 664,579 primes and used about the same CPU time, a little under a second. Threads did not make the job smaller; they spread it over more cores, so the wall time fell more than tenfold from one thread to 18.
- Cores busy can never pass the hardware. The last column grows with the number of threads, but it cannot exceed 18 here, however many threads the program starts, because only 18 can run at once, so 36 threads were not reliably faster than 18: in repeated runs they were sometimes a little faster and sometimes slower. With other programs competing for the same cores, even one thread got less than a whole core, and 18 threads got fewer than 18.
- The work has to be shared fairly. The program deals numbers out in chunks of 10,000, round robin, because large numbers take longer to test than small ones; one contiguous block per thread would leave the threads with the small numbers idle while the last thread finished.
Starting a thread costs the operating system work too, so a thread is worth it only for work that takes much longer than starting it. For thousands of small tasks, a fixed set of threads that take tasks from a queue (a thread pool, built later in this module) is the usual design, as the Core Guidelines advise (CP.41).
Your turn: checksums on N threads
checksum_all() below computes the CRC-32 of twelve in-memory “files”, and it does all the work on the calling
thread. Make it share the files out among n_threads std::jthread workers, each with a contiguous slice and each
writing only its own elements of the result:
// Exercise: make checksum_all() share the files out among n_threads std::jthread workers, then build and run this
// file. Every check must say "ok".
// clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined checksum_exercise.cpp -o checksum_exercise
// ./checksum_exercise
#include <algorithm>
#include <cstddef>
#include <cstdint>
#include <format>
#include <print>
#include <set>
#include <string>
#include <string_view>
#include <thread>
#include <vector>
// CRC-32 (the checksum of zip and PNG files, CRC-32/ISO-HDLC), one bit at a time. Nothing to change here.
std::uint32_t crc32(std::string_view data) {
std::uint32_t crc = 0xFFFF'FFFFu;
for (unsigned char byte : data) {
crc ^= byte;
for (int bit = 0; bit < 8; ++bit) crc = (crc >> 1) ^ (0xEDB8'8320u & (0u - (crc & 1u)));
}
return ~crc;
}
struct Checksum {
std::uint32_t crc = 0;
std::thread::id worker; // the thread that computed it
};
// Returns the CRC-32 of every file, in the same order as `files`, and which thread computed each one.
// * Use min(n_threads, files.size()) std::jthread workers: never more threads than files, and none for no files.
// * n_threads == 0 means "as many as std::thread::hardware_concurrency() suggests", or 1 if it returns 0.
// * Give each worker its own contiguous slice of the files; a worker writes only its own elements of the result.
std::vector<Checksum> checksum_all(const std::vector<std::string>& files, unsigned n_threads) {
// Starting point: every file is checksummed on the calling thread. Share the work out instead.
std::vector<Checksum> result(files.size());
for (std::size_t i = 0; i < files.size(); ++i) result[i] = {crc32(files[i]), std::this_thread::get_id()};
return result;
}
int checks = 0;
int failures = 0;
void check(bool passed, std::string_view what) {
std::println("{} {}", passed ? "ok " : "FAILED", what);
++checks;
if (!passed) ++failures;
}
// Checks one call: the right checksums in the right order, computed by `want` different threads, none of them main.
void check_call(const std::vector<std::string>& files, unsigned n_threads, std::size_t want) {
const std::vector<Checksum> got = checksum_all(files, n_threads);
bool same = got.size() == files.size();
std::set<std::thread::id> workers;
for (std::size_t i = 0; same && i < files.size(); ++i) {
same = got[i].crc == crc32(files[i]);
workers.insert(got[i].worker);
}
check(same, std::format("n_threads = {}: {} checksums, in order", n_threads, files.size()));
check(workers.size() == want && !workers.contains(std::this_thread::get_id()),
std::format("n_threads = {}: computed by {} worker thread{}, none of them main (got {}{})", n_threads, want,
want == 1 ? "" : "s", workers.size(), workers.contains(std::this_thread::get_id()) ? ", main included" : ""));
}
int main() {
check(crc32("123456789") == 0xCBF4'3926u, "crc32(\"123456789\") is the standard check value CBF43926");
std::vector<std::string> files; // twelve "files" of different sizes, kept in memory
for (int i = 0; i < 12; ++i) files.push_back(std::string(5'000 * (i + 1), static_cast<char>('a' + i)));
for (unsigned n : {1u, 2u, 3u, 4u, 8u}) check_call(files, n, n);
check_call(files, 50, files.size()); // more threads asked for than there are files
const unsigned hc = std::thread::hardware_concurrency();
check_call(files, 0, std::min<std::size_t>(hc == 0 ? 1 : hc, files.size()));
check(checksum_all({}, 4).empty(), "no files: an empty result");
std::println("{} of {} checks failed", failures, checks);
} As it starts, every checksum is right, but every thread check fails, because main computed them all:
from session import run
# The exercise as it starts: every checksum is right, but main computes them all, so the thread checks fail.
run("""
clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined checksum_exercise.cpp -o checksum_exercise
./checksum_exercise
""") Output
$ clang++ -std=c++23 -Wall -Wextra -fsanitize=address,undefined checksum_exercise.cpp -o checksum_exercise
checksum_exercise.cpp:35:84: warning: unused parameter 'n_threads' [-Wunused-parameter]
35 | std::vector<Checksum> checksum_all(const std::vector<std::string>& files, unsigned n_threads) {
| ^
1 warning generated.
$ ./checksum_exercise
ok crc32("123456789") is the standard check value CBF43926
ok n_threads = 1: 12 checksums, in order
FAILED n_threads = 1: computed by 1 worker thread, none of them main (got 1, main included)
ok n_threads = 2: 12 checksums, in order
FAILED n_threads = 2: computed by 2 worker threads, none of them main (got 1, main included)
ok n_threads = 3: 12 checksums, in order
FAILED n_threads = 3: computed by 3 worker threads, none of them main (got 1, main included)
ok n_threads = 4: 12 checksums, in order
FAILED n_threads = 4: computed by 4 worker threads, none of them main (got 1, main included)
ok n_threads = 8: 12 checksums, in order
FAILED n_threads = 8: computed by 8 worker threads, none of them main (got 1, main included)
ok n_threads = 50: 12 checksums, in order
FAILED n_threads = 50: computed by 12 worker threads, none of them main (got 1, main included)
ok n_threads = 0: 12 checksums, in order
FAILED n_threads = 0: computed by 12 worker threads, none of them main (got 1, main included)
ok no files: an empty result
7 of 16 checks failed
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 exercise_session.py
You are done when the program prints 0 of 16 checks failed. The rules are in the comment above the function:
never more threads than files, n_threads == 0 means hardware_concurrency(), and 1 when that returns 0. Hints:
files.size() * t / n and files.size() * (t + 1) / n give the slice of worker t of n without leaving any file
out; a std::vector<std::jthread> declared inside a block joins every worker at the closing brace, before the
function returns the result. Build it once more with -fsanitize=thread instead of address,undefined: a correct
solution produces no ThreadSanitizer report.
Check yourself
Five questions on threads and std::jthread
5 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What happens if a std::thread object is destroyed while it is still joinable, and how do you avoid it?
Its destructor calls std::terminate(), which ends the program, usually with abort(). A thread is joinable from
the moment it starts until join() or detach() is called, even after its function has returned. To avoid it, make
sure every path joins: call join() before the object goes out of scope, including the paths where an exception
leaves the function early. The simplest fix is std::jthread (C++20), whose destructor requests a stop and then
joins. detach() also avoids the terminate, but then nobody waits for the thread, which keeps running after the
function returns and can use objects that no longer exist. The standard terminates on purpose: an automatic join
could make a program wait where nobody expects it, and an automatic detach could leave a thread using dead objects.
Key takeaways
std::thread t(f, args...)starts runningf(args...)at once;t.join()waits for it and makes everything it wrote visible to the joining thread.- A joinable
std::threadmust be joined or detached before it is destroyed, or the program terminates; preferstd::jthread, which requests a stop and joins in its destructor. - Threads that each write only their own data need no locks; split the work so that every thread has a similar amount.
- Arguments are copied or moved into the thread; use
std::refonly for objects that outlive the thread, and never detach a thread that refers to local variables. - Stopping is cooperative:
request_stop()sets the token, and the thread checksstop_requested()at points where it is safe to stop. hardware_concurrency()is a hint, and threads beyond it do not make work that needs the processor faster.
References
- Working Draft, Programming Languages: C++ (N4950), clause 33.4: Threads (ISO/IEC JTC1/SC22/WG21)
- Stop Token and Joining Thread, Rev 10 (P0660R10) (ISO/IEC JTC1/SC22/WG21)
- C++ Core Guidelines: CP, Concurrency and parallelism (Standard C++ Foundation)
- AddressSanitizer (Clang documentation) (LLVM Project)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress