C++ concurrency · Part 1

Creating and managing threads

What does it mean to run several flows of execution? Creating threads with std::thread, managing their lifetime with join/detach, and C++20 jthread with its automatic join and cooperative cancellation.

A discussion of concurrency should not start with locks or signals but with what comes before them: how do you get several flows of execution in the first place? Locks presuppose that multiple threads are already running. This part covers that premise — creating threads and managing their lifetime.

What is a thread?

An ordinary program has a single flow of execution. It starts at main and works down line by line. A thread multiplies that flow. Several threads run inside the same process, sharing the same memory, simultaneously (or in alternation).

Sharing memory is both the power and the danger. Threads can exchange data easily, but touching the same data at the same time causes problems. Handling those problems is what the later parts on locking and synchronisation are about. This part concentrates on the step before that: starting and cleaning up threads safely.

std::thread: starting a flow of execution

Since C++11 the standard library provides std::thread. Pass a callable (a function, a lambda, a function object) to the constructor and a new thread starts executing immediately.

#include <thread>
#include <iostream>

void work() {
    std::cout << "running on another thread\n";
}

int main() {
    std::thread t(work);   // a new thread starts at this moment
    t.join();              // wait until t finishes
}

The single line std::thread t(work) creates a new flow of execution, and that flow proceeds concurrently with main. A lambda works exactly the same way.

std::thread t([]{
    std::cout << "running from a lambda\n";
});
t.join();

join and detach: you must choose one

When a std::thread object is destroyed, if the thread has been neither joined nor detached, the program dies immediately via std::terminate. This is deliberate C++ design. It forces you to state explicitly what should happen to the thread you started.

join() blocks the current thread until that thread finishes. Use it when you need the thread’s results or side effects, and when resources the thread references must stay alive.

std::thread t(work);
// ... other work ...
t.join();   // the next line is only reached once t is done

detach() cuts the thread loose to run independently. The connection with the thread object is severed, and the thread runs to completion on its own.

std::thread t(work);
t.detach();   // t now runs independently in the background

detach is tricky to handle. If a detached thread outlives main, then when main ends and the process terminates the thread may be cut off mid-flight, or reference already-destroyed resources and cause undefined behaviour. So in practice most code uses join, and detach only with care when the lifetime is firmly under control.

The join trap: exceptions

There is a subtle problem here. What happens if an exception is thrown before join is called?

void process() {
    std::thread t(work);
    doSomethingThatMayThrow();   // if this throws
    t.join();                    // this line is never reached
}

When the exception is thrown, t.join() is skipped and t is destroyed. The thread was neither joined nor detached, so the program dies via std::terminate. This is structurally the same problem as the deadlock in the next part, where an exception skips a hand-written mutex unlock. Cleanup (the join) must be guaranteed on every exit path, and by hand you will miss one.

The fix is the same too: wrap it in RAII. Hold the thread as a member and join in the destructor. And C++20 made exactly that a standard type.

std::jthread: automatic join and cancellation (C++20)

std::jthread is the improved std::thread. The j stands for joining. Two things differ.

First, it joins automatically in its destructor. Leaving scope joins it for you, so there is no terminate from a missed join and no need to worry about exception paths. RAII is built in.

void process() {
    std::jthread t(work);
    doSomethingThatMayThrow();   // even if this throws
}                                // the destructor joins automatically

Second, it supports cooperative cancellation. Through std::stop_token you can send a thread the signal “stop now”. If the function takes a std::stop_token as its first argument, jthread passes it in automatically.

#include <thread>

void worker(std::stop_token st) {
    while (!st.stop_requested()) {
        // repeated work
    }
    // exit the loop and shut down cleanly
}

int main() {
    std::jthread t(worker);
    // ... other work ...
    t.request_stop();   // request a stop; worker checks and exits on its own
}                       // the destructor calls request_stop, then joins

The word “cooperative” is the key. request_stop() does not forcibly kill the thread. It merely raises a flag saying “please stop”, and the thread has to check stop_requested() itself and break out. Forcibly terminating a thread from the outside risks cutting it off before it can clean up its resources, which is why no language recommends it. Cooperative cancellation instead safely leaves the exit point to the thread itself.

A jthread’s destructor calls request_stop() automatically and then joins. So even without the explicit request_stop() in the example above, the stop request and the join both happen when scope is left.

In short, since C++20 the default choice is jthread. It is safe thanks to the automatic join, and it has a cancellation mechanism built in. Use thread only when you do not want that behaviour or need fine-grained manual control.

Passing arguments: copies and std::ref

To pass arguments to a thread function, list them after the callable in the thread constructor. Note that arguments are copied by default.

void printValue(int x) {
    std::cout << x << "\n";
}

std::jthread t(printValue, 42);   // 42 is copied and passed

Even if you want to pass by reference, passing it plainly makes a copy. To share the original you must wrap it explicitly in std::ref.

void increment(int& x) {
    ++x;
}

int value = 0;
std::jthread t(increment, std::ref(value));   // pass value by reference
t.join();
// value is now 1

Passing increment(value) without std::ref either fails to compile (for a reference parameter) or modifies only the copy while the original is untouched. Passing by reference has to go through std::ref (or std::cref for const references).

There is a lifetime trap here too. If the original you passed by reference disappears before the thread does, the thread ends up pointing at something gone. jthread’s automatic join greatly reduces that risk, because you can arrange the join to happen before the scope holding the original ends.

How many threads should you create?

Threads are not free. Each takes stack memory, and there is a cost to creating and destroying them and to switching between them. Creating far more threads than there are cores actually eats into performance through switching overhead.

You can ask for a hint about how many threads the hardware can run at once.

unsigned n = std::thread::hardware_concurrency();
// the number of threads this machine can run concurrently (0 if unknown)

In practice, rather than creating and discarding threads each time, you use a thread pool — create a few up front and hand work out to them. The channel for delivering work to each worker is exactly the lock- and condition-variable-based work queue covered in the coming parts.

Summary

  • Thread: multiple flows of execution sharing the same memory. The sharing is both the power and the danger.
  • std::thread: starts executing on construction. Before destruction you must join or detach (otherwise terminate).
  • join vs detach: mostly join (wait until it finishes). detach only when the lifetime is firmly controlled.
  • Exception trap: a hand-written join is easily skipped on an exception path. Wrap it in RAII.
  • std::jthread (C++20): automatic join in the destructor + cooperative cancellation (stop_token). The default choice since C++20.
  • Passing arguments: copies by default. To pass by reference use std::ref/std::cref.
  • Thread count: overshooting the core count costs you in switching overhead. In practice, use a thread pool.

Now that we have several flows of execution running, the problem of them touching the same data remains. The next part covers safely locking that shared resource: mutexes and RAII-based locking.