Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Clang 21.0.0, GCC 16.2.0 and Python 3.14.8
  • By MySmartCoPilot
Free: 25 minutes

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:

sum_threads.cpp C++ · sum_threads.cpp
// 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
}
main fills a vector, starts four threads that each sum a quarter into their own partial slot, then joins them and adds the four results.main: fill numberswith 1 … 10,000,000four threads run at the same timemain: join() each thread,then add partial[0 … 3]thread 0sums the 1st quarterwrites partial[0]thread 1sums the 2nd quarterwrites partial[1]thread 2sums the 3rd quarterwrites partial[2]thread 3sums the 4th quarterwrites partial[3]start 4 threadseach join() waitsfor one thread

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:

Four threads, two compilers Python · sum_session.py
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 reads numbers. 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 from join(). Reading partial before the joins would read values that may not be there yet.
  • The references stay valid. The lambda captures numbers and partial by reference, which is safe only because both outlive every thread: main joins 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:

forgot_join.cpp C++ · forgot_join.cpp
// 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()
std::terminate() with libc++ and with libstdc++ Python · terminate_session.py
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(); and parse throws, t is destroyed while still joinable, and the exception ends the program instead of reaching its handler;
  • wrapping the code in try and calling join() 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:

dangling_detach.cpp C++ · dangling_detach.cpp
// 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:

A detached thread reads a dead local variable Python · detach_session.py
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:

jthread_scope.cpp C++ · jthread_scope.cpp
// 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:

std::thread or std::jthread?
Criterion std::threadstd::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 threadrequest_stop() and the std::stop_token it passes
Size on the computer that ran the examples 8 bytes16 bytes, plus a stop state allocated when it starts a thread
When to choose Code that must still build as C++17Everything 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:

stop_token.cpp C++ · stop_token.cpp
// 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());
}
jthread_scope.cpp and stop_token.cpp with both compilers Python · stop_session.py
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() returns true when this call made the request and false when a stop had already been requested, so exactly one caller “wins”.
  • stop_requested() on the token becomes true and stays true. The worker checks it once per round, so it stops within one round, here at most 5 milliseconds later.
  • A std::stop_callback registered on the token runs when the stop is requested, in the thread that requests it, before request_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:

arguments.cpp C++ · arguments.cpp
// 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");
}
needs_ref.cpp C++ · needs_ref.cpp
// 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();
}
A copy, std::ref, a move, and the error without std::ref Python · args_session.py
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

  1. A copy by default. show() saw “first”, because the thread holds its own copy of text, made before the constructor returned; changing text in main afterwards touches only main’s string.
  2. A reference only on request. add_one(int&) cannot take the thread’s stored copy, which is passed as an rvalue, so needs_ref.cpp does 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.
  3. Move-only types are moved. A std::unique_ptr cannot be copied, so std::move hands its ownership to the thread’s function, and main’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:

speedup.cpp C++ · speedup.cpp
// 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);
    }
}
Counting primes with 1 to 36 threads Python · speedup_session.py
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:

checksum_exercise.cpp C++ · checksum_exercise.cpp
// 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:

The starting point Python · exercise_session.py
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.

CRC Calculator (CRC-8/16/32/64) Compute the CRC-32 of your own text to compare with crc32() in the exercise: pick CRC-32/ISO-HDLC.

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.

  1. Question 1 of 5 A function starts a std::thread and returns without calling join() or detach() on it. What happens?

    Choose one answer.

    Show the answer to question 1

    Answer: The thread object's destructor calls std::terminate(), which ends the whole program

    The destructor of a std::thread that is still joinable calls std::terminate(). The recorded runs show it with both libraries: "libc++abi: terminating" and "terminate called without an active exception", then exit status 134. std::jthread is the type whose destructor asks the thread to stop and then joins it.

  2. Question 2 of 5 void bump(int& n) { ++n; } is given. Which line starts a thread that increments count in place?

    Read the code, then choose one answer.

    int count = 0;
    // (a) std::thread t(bump, count);
    // (b) std::thread t(bump, std::ref(count));
    // (c) std::thread t(bump, &count);
    // (d) std::thread t(bump, std::move(count));
    Show the answer to question 2

    Answer: (b), and count must outlive the thread

    std::thread stores its own copy of every argument and passes that copy as an rvalue, which an int& cannot bind to, so (a) and (d) do not compile. (c) passes an int*, which does not convert to int&. std::ref wraps a reference that converts to int&; count must then live at least until the thread has been joined.

  3. Question 3 of 5 What does the destructor of a std::jthread that is still running a thread do?

    Choose one answer.

    Show the answer to question 3

    Answer: It calls request_stop() and then join()

    ~jthread() asks the thread to stop through its stop state and then waits for it. The thread stops only if its function checks the std::stop_token it was given; a function that never looks at the token keeps the destructor waiting until it returns.

  4. Question 4 of 5 std::thread::hardware_concurrency() returns 0. What does that mean?

    Choose one answer.

    Show the answer to question 4

    Answer: The library could not tell how many hardware threads there are; choose a sensible default yourself

    The standard calls the value a hint and says an implementation should return 0 when it cannot be computed. Treat 0 as unknown: the exercise in this lesson uses 1 thread in that case, and many programs use a fixed number such as 4.

  5. Question 5 of 5 A worker registers a std::stop_callback on its token, and later main calls worker.request_stop(). In which thread does the callback run?

    Choose one answer.

    Show the answer to question 5

    Answer: In main, inside the request_stop() call, before it returns

    Callbacks registered before the request run synchronously in the thread that makes the request, which is why stop_token.cpp printed "callback: ran on the main thread: true". A callback registered after a stop was already requested runs at once, in the thread that registers it.

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 running f(args...) at once; t.join() waits for it and makes everything it wrote visible to the joining thread.
  • A joinable std::thread must be joined or detached before it is destroyed, or the program terminates; prefer std::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::ref only 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 checks stop_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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.