EduC++ / Creating and Managing Threads

Creating and Managing Threads

Prereqs Basic understanding of functions, lambdas, and std::ref.
Standard C++11 introduced std::thread. C++20 added std::jthread with automatic joining and cooperative cancellation via std::stop_token.

Frequently Asked Questions

QHow many threads should I create?
AFor CPU-bound work, start with std::thread::hardware_concurrency() threads (one per logical core). Creating more threads than cores causes excessive context switching and cache thrashing, which hurts performance. For I/O-bound work, you can use more threads since they spend most of their time blocked. In practice, use a thread pool sized to the hardware concurrency and submit tasks to it rather than spawning threads ad hoc.
QWhat happens if a thread throws an exception?
AIf an exception escapes a thread's top-level function, std::terminate() is called and the entire program aborts. Exceptions do not propagate to the joining thread automatically. To transfer exceptions across threads, catch them inside the thread and store them via std::exception_ptr, then rethrow in the joining thread. std::async handles this automatically by capturing exceptions in the returned future.
QWhat is thread_local storage and when should I use it?
AThe thread_local keyword gives each thread its own independent copy of a variable. It is useful for per-thread caches, random number generators, error codes, or avoiding contention on shared state. thread_local variables are initialized the first time the thread accesses them and destroyed when the thread exits. Be cautious with thread_local in thread pools -- the variable persists across task invocations on the same thread.
QWhat are the risks of calling detach() on a thread?
AA detached thread runs independently with no way to join it or check its status. If main() returns or local variables go out of scope while the detached thread is still running, it will access destroyed objects, causing undefined behavior. Detached threads also make clean shutdown difficult. Prefer jthread (C++20) with cooperative cancellation, or design the thread to be joinable so you can guarantee it has finished before destroying any state it references.
C++
#include <iostream>
#include <thread>
#include <format>
#include <vector>
#include <stop_token>

void simple_task(int id) {
    std::cout << std::format("Thread {} starting\n", id);
    std::this_thread::sleep_for(std::chrono::milliseconds(100));
    std::cout << std::format("Thread {} finished\n", id);
}

void task_with_result(int id, int& result) {
    result = id * id;
}

C++
int main() {
    std::cout << std::format("Hardware threads: {}\n",
                             std::thread::hardware_concurrency());

1 Basic thread creation

What

Basic thread creation.

When

Prefer std::jthread (C++20) which auto-joins.

Why

detach() calls std::terminate.

Use

Use it in code like: std::thread t1(simple_task, 1);.

C++ Version C++20

detach() calls std::terminate. Prefer std::jthread (C++20) which auto-joins.

Watch out: a std::thread that goes out of scope without join() or

C++
std::thread t1(simple_task, 1);
    std::thread t2(simple_task, 2);

    // Must join or detach before destructor!
    t1.join();  // Wait for completion
    t2.join();

2 Thread with reference parameter

What

Thread with reference parameter.

When

Use this when it cleanly solves the problem in front of you.

Why

wrap references in std::ref(); forgetting this causes a compile error or silent copy.

Use

Use it in code like: int result = 0;.

C++ Version Not explicitly specified in this example.

wrap references in std::ref(); forgetting this causes a compile error or silent copy.

Watch out: std::thread copies its arguments by default. You must

C++
int result = 0;
    std::thread t3(task_with_result, 5, std::ref(result));
    t3.join();
    std::cout << std::format("Result: {}\n", result);

3 Lambda threads

What

A lambda defines an unnamed callable object directly at the use site.

When

Use lambdas for short callbacks, predicates, and local behavior.

Why

They keep behavior close to the call site and avoid extra named functor types.

Use

Write [captures](params) { body } and keep captured state explicit.

C++ Version C++11

running even after main() returns, potentially accessing destroyed objects. Only detach when the thread is truly self-contained.

Watch out: detach() makes the thread independent -- it continues

C++
std::vector<std::thread> workers;
    for (int i = 0; i < 4; ++i) {
        workers.emplace_back([i] {
            std::cout << std::format("Lambda worker {} running\n", i);
        });
    }

    for (auto& w : workers) {
        w.join();
    }

4 std::jthread -- C++20 auto-joining thread with cooperative

What

std::jthread -- C++20 auto-joining thread with cooperative.

When

Use this when it cleanly solves the problem in front of you.

Why

cancellation via stop_token thread ignores the stop_token it will still block until the thread finishes naturally.

Use

Use it in code like: std::cout << "\n--- std::jthread with stop_token ---\n";.

C++ Version C++20

cancellation via stop_token thread ignores the stop_token it will still block until the thread finishes naturally.

Watch out: jthread's destructor requests stop AND joins. If your

C++
std::cout << "\n--- std::jthread with stop_token ---\n";
    {
        std::jthread worker([](std::stop_token stoken) {
            int count = 0;
            while (!stoken.stop_requested()) {
                std::cout << std::format("jthread working... (iteration {})\n", ++count);
                std::this_thread::sleep_for(std::chrono::milliseconds(50));
            }
            std::cout << "jthread received stop request, exiting cleanly\n";
        });

        // Let the worker run for a bit
        std::this_thread::sleep_for(std::chrono::milliseconds(180));

        // jthread destructor calls request_stop() then join() automatically
        std::cout << "jthread going out of scope (auto-stop + auto-join)\n";
    }

    std::cout << "All threads completed\n";
    return 0;
}