CodeRigor

FREE LESSON

Threads, jthread and cooperative cancellation

Start, stop and join threads safely: jthread and stop tokens for cancellation, mutexes and condition variables without races or deadlocks, futures for results.

Concurrency bugs are the worst kind: they hide on your machine and appear on someone else's. The way to write threaded code you can trust is to make every interaction between threads go through one of a few well-understood tools — join, mutex, condition variable, atomic, future — and to check the result with ThreadSanitizer (every snippet and test in this skill runs under it).

1. Starting and finishing threads

A std::thread starts running its function immediately. Before it is destroyed it must be joined (wait for it to finish) or detached (let it run on, unowned). Destroying a joinable std::thread calls std::terminate:

C++
void oops() {
    std::thread t([] { /* ... */ });
}   // ~thread on a joinable thread: std::terminate()

C++20's std::jthread fixes this: its destructor requests stop and joins. Prefer it everywhere. Detaching is almost never right — a detached thread can outlive everything it refers to, and nothing tells you when it's done.

C++
#include <print>
#include <thread>
#include <vector>

int main() {
    std::vector<int> results(4);
    {
        std::vector<std::jthread> workers;
        for (int i = 0; i < 4; ++i)
            workers.emplace_back([i, &slot = results[i]] { slot = i * i; });
    }   // every jthread joins here: their writes happen-before what follows
    for (int r : results) std::print("{} ", r);
    std::println();
}
Join is a synchronisation point
Each worker writes its own slot. The joins at the end of the scope synchronise with the workers' completion, so their writes happen-before main's reads.
Join is a synchronisation point
Each worker writes its own slot. The joins at the end of the scope synchronise with the workers' completion, so their writes happen-before main's reads.

Each thread writes only its own slot, and the main thread reads the slots only after the joins. That's the simplest safe pattern there is.

2. Passing arguments: copies by default

std::thread/std::jthread copy (or move) every argument into the new thread, so a function taking int& would bind to the copy — and that doesn't compile. Say "reference" explicitly with std::ref:

C++
#include <functional>
#include <print>
#include <thread>

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

int main() {
    int n = 0;
    { std::jthread t(add_one, std::ref(n)); }   // joined before n is read
    std::println("{}", n);                      // 1
}
C++
#include <thread>

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

int main() {
    int n = 0;
    std::jthread t(add_one, n);   // can't bind int& to the thread's copy
}

The same goes for lambdas: a capture by reference is only safe if the thread finishes before the referenced object dies — which jthread + a scope gives you, and detach throws away.

3. Data races, mutexes and locks

A data race is two threads accessing the same memory, at least one writing, with no synchronisation ordering them. It is undefined behaviour — not just "a wrong number sometimes". A race condition is the broader logic bug where the result depends on timing (e.g. check-then-act), even with every access properly locked.

A std::mutex serialises access. Never call lock()/unlock() by hand; use RAII guards:

Guard Use
std::lock_guard lock one mutex for a scope
std::scoped_lock lock several mutexes at once, deadlock-free
std::unique_lock movable, can unlock early — required by condition variables
C++
#include <mutex>
#include <print>
#include <thread>
#include <vector>

int main() {
    std::mutex m;
    long counter = 0;
    {
        std::vector<std::jthread> ts;
        for (int t = 0; t < 4; ++t)
            ts.emplace_back([&] {
                for (int i = 0; i < 10'000; ++i) {
                    std::lock_guard lock(m);
                    ++counter;
                }
            });
    }
    std::println("{}", counter);   // 40000, always
}
A mutex turns overlapping increments into a sequence
Only one thread holds m at a time. Each unlock synchronises with the next lock of m, so every ++counter happens-before the next one: no race, no lost update.
A mutex turns overlapping increments into a sequence
Only one thread holds m at a time. Each unlock synchronises with the next lock of m, so every ++counter happens-before the next one: no race, no lost update.

Deadlock

If one path locks a then b and another locks b then a, each can hold one mutex and wait forever for the other. ThreadSanitizer reports this as a lock-order inversion the first time it could happen, even if your run didn't freeze. The fix is to never take nested locks in inconsistent orders — and when you need two at once, let std::scoped_lock(a, b) take them: it uses a deadlock-avoidance algorithm.

Deadlock is a cycle in the wait-for graph
Thread 1 holds a and waits for b; thread 2 holds b and waits for a. Neither can move. std::scoped_lock(a, b) never holds one while blocking on the other.
Deadlock is a cycle in the wait-for graph
Thread 1 holds a and waits for b; thread 2 holds b and waits for a. Neither can move. std::scoped_lock(a, b) never holds one while blocking on the other.

Keep callbacks outside the lock

Core Guidelines CP.22 and CP.43 connect lock ownership to interface design: a callback is arbitrary caller code. It can take other locks, reenter this object or run slowly. Copy the required state under the lock, release the lock, then call it. The copy has snapshot semantics; it is not a promise that the live object cannot change before the callback runs.

C++
#include <functional>
#include <mutex>
#include <print>
#include <span>
#include <vector>
class Samples {
    mutable std::mutex mutex_;
    std::vector<int> values_{1, 2, 3};
public:
    std::vector<int> snapshot() const {
        std::lock_guard guard(mutex_);
        return values_;
    }
    void visit(const std::function<void(std::span<const int>)>& callback) const {
        auto owned = snapshot();
        callback(owned); // snapshot's guard has already released the mutex
    }
};
int main() {
    Samples samples;
    samples.visit([&](std::span<const int> view) {
        auto again = samples.snapshot(); // safe reentry: no outer lock is held
        std::println("{} {}", view.size(), again.size());
    });
}

Name lock objects (CP.44) so their lifetime covers the intended critical section, and keep the mutex beside the state it guards (CP.50). An unnamed temporary guard can release the mutex at the end of its statement.

Coroutines add a related boundary: do not retain a thread-affine lock across suspension (CP.52). A resumed coroutine might run elsewhere, and suspended work can block other users of the resource. Capturing coroutine lambdas can also outlive the closure holding their captures (CP.51); pass owned state as value parameters to a suitable coroutine instead. Reference parameters need an explicit external lifetime contract (CP.53). These are reasoning checks here, not a graded coroutine runtime or a proof of general race freedom.

4. Condition variables: wait for a condition

A condition variable lets a thread sleep until another thread changes something. The waiter must hold a unique_lock; wait atomically releases it while sleeping and re-acquires it on wake-up.

Wake-ups can be spurious (no notify at all) or meant for another waiter, so a waiter must re-check its condition every time. Always use the overload that takes a predicate — it loops for you:

C++
std::unique_lock lock(m);
cv.wait(lock, [&] { return ready; });   // returns only when ready is true
wait releases the mutex while sleeping and re-takes it on wake-up
The waiter checks ready under the lock and goes to sleep with m released, so the notifier can take it. After every wake-up — spurious or not — the predicate is checked again, under the lock.
wait releases the mutex while sleeping and re-takes it on wake-up
The waiter checks ready under the lock and goes to sleep with m released, so the notifier can take it. After every wake-up — spurious or not — the predicate is checked again, under the lock.

Change the condition under the mutex, then notify (notifying after unlocking is fine and avoids waking a thread straight into a held lock).

5. Cooperative cancellation: stop tokens

std::jthread owns a std::stop_source. If the thread function's first parameter is a std::stop_token, jthread passes it in; request_stop() (or the jthread's destructor) flips it. Cancellation is cooperative — nothing interrupts the thread; it must check stop_requested() or wait on something that wakes on a stop request.

A std::stop_callback runs a function when stop is requested — immediately, in its constructor, if it already was:

C++
#include <print>
#include <stop_token>

int main() {
    std::stop_source src;
    std::stop_callback cb(src.get_token(), [] { std::println("callback"); });
    std::println("before");
    src.request_stop();      // runs the callback on this thread, right now
    std::println("after");
}

std::condition_variable_any has a wait that takes a stop token: cv.wait(lock, token, pred) returns when pred() is true or stop is requested, and returns pred()'s value. It registers the stop callback for you, so a blocked waiter really wakes up on cancellation.

Worked example: a cancellable mailbox

A one-slot mailbox: put stores a value and wakes a reader; take waits for a value, or gives up when cancelled.

C++
#include <condition_variable>
#include <mutex>
#include <optional>
#include <print>
#include <stop_token>
#include <thread>
#include <utility>

class Mailbox {
public:
    void put(int v) {
        {
            std::lock_guard lock(m_);
            value_ = v;                     // change the condition under the mutex
        }
        cv_.notify_one();
    }

    std::optional<int> take(std::stop_token st) {
        std::unique_lock lock(m_);
        if (!cv_.wait(lock, st, [this] { return value_.has_value(); }))
            return std::nullopt;            // woken by a stop request
        return std::exchange(value_, std::nullopt);
    }

private:
    std::mutex m_;
    std::condition_variable_any cv_;
    std::optional<int> value_;
};

int main() {
    Mailbox box;
    std::optional<int> got;
    {
        std::jthread reader([&](std::stop_token st) { got = box.take(st); });
        box.put(42);
    }
    std::println("{}", got.value_or(-1));    // 42

    std::optional<int> cancelled = 0;
    {
        std::jthread reader([&](std::stop_token st) { cancelled = box.take(st); });
    }   // ~jthread requests stop: take() wakes and returns nullopt
    std::println("{}", cancelled.has_value());   // false
}

Note what makes this correct: the value is only touched under m_; the waiter uses a predicate; the stop-aware wait handles cancellation; and both reads of got/cancelled happen after the jthread has joined.

Faded example — your turn

A CountDown lets threads wait until n events have happened. Fill in wait, so that it returns true when the count reaches zero and false if cancelled first:

C++
class CountDown {
public:
    explicit CountDown(int n) : left_(n) {}
    void tick() {
        { std::lock_guard lock(m_); if (left_ > 0) --left_; }
        cv_.notify_all();
    }
    bool wait(std::stop_token st) {
        std::unique_lock lock(m_);
        return /* ??? */;
    }
private:
    std::mutex m_;
    std::condition_variable_any cv_;
    int left_;
};
Answer

cv_.wait(lock, st, [this] { return left_ == 0; }) — the predicate is the condition you're waiting for, and the stop-token overload returns its value. (The standard library already has this: std::latch, without cancellation.)

6. Latches, barriers and semaphores

Tool What it does
std::latch(n) one-shot: threads count_down(); wait() blocks until it hits zero
std::barrier(n) reusable: arrive_and_wait() blocks until all n arrive, then the next phase starts
std::counting_semaphore<N> at most N holders at a time (acquire/release)
C++
#include <barrier>
#include <latch>
#include <mutex>
#include <print>
#include <thread>
#include <vector>

int main() {
    std::latch ready(3);
    std::barrier sync(3);
    int phase_sums[2] = {0, 0};
    std::mutex m;
    {
        std::vector<std::jthread> ts;
        for (int id = 1; id <= 3; ++id)
            ts.emplace_back([&, id] {
                ready.count_down();
                for (int phase = 0; phase < 2; ++phase) {
                    { std::lock_guard lock(m); phase_sums[phase] += id; }
                    sync.arrive_and_wait();   // nobody starts phase 1 early
                }
            });
        ready.wait();
    }
    std::println("{} {}", phase_sums[0], phase_sums[1]);   // 6 6
}

7. Futures, packaged tasks and std::async

A std::future<T> is a one-shot channel for a result or an exception. std::packaged_task<R()> wraps a callable so that running it fills its future; if the task is destroyed without running, the future reports std::future_errc::broken_promise. That's the building block of a thread pool: queue packaged tasks, hand out their futures.

std::async(std::launch::async, f) runs f on a new thread and returns a future — with a famous gotcha: the future returned by std::async blocks in its destructor until the task finishes. Discarding it makes the "async" call synchronous:

C++
std::async(std::launch::async, slow_a);   // temporary future destroyed here:
std::async(std::launch::async, slow_b);   // waits for slow_a before starting slow_b
A discarded std::async future waits in its destructor
The temporary future from the first call is destroyed at the end of that statement, and that destructor blocks until slow_a finishes. slow_b only starts afterwards.
A discarded std::async future waits in its destructor
The temporary future from the first call is destroyed at the end of that statement, and that destructor blocks until slow_a finishes. slow_b only starts afterwards.

Keep the futures (auto fa = std::async(...)) if you want concurrency.

8. Initialising once

Local static variables are initialised exactly once, thread-safely ("magic statics"). For anything else, std::once_flag + std::call_once does the same. Don't hand-roll double-checked locking with plain variables — it's a data race.

C++
#include <mutex>
#include <print>
#include <thread>
#include <vector>

int expensive() {
    static int value = [] { std::println("initialising"); return 42; }();
    return value;
}

int main() {
    std::vector<int> got(4);
    {
        std::vector<std::jthread> ts;
        for (int i = 0; i < 4; ++i) ts.emplace_back([&, i] { got[i] = expensive(); });
    }
    std::println("{} {} {} {}", got[0], got[1], got[2], got[3]);   // "initialising" printed once
}

Summary

  • Use std::jthread: it joins (and requests stop) in its destructor. Don't detach.
  • Arguments are copied into the thread; use std::ref deliberately, and only for objects that outlive the thread.
  • Share data only through a mutex, an atomic, or a join. A data race is UB even when the numbers look right.
  • Lock several mutexes with std::scoped_lock; inconsistent lock order deadlocks.
  • Always wait on a condition variable with a predicate; use condition_variable_any::wait(lock, stop_token, pred) for cancellable waits.
  • std::packaged_task + std::future carry results and exceptions between threads; std::async's future blocks on destruction.
  • Run everything under ThreadSanitizer.

Watch & read

Free lectures, talks and readings that cover this skill from another angle. All resources →

Threads, joining, mutexes, condition variables, latches, barriers and futures in one well-paced hour — the toolbox this skill uses.

From the author of the proposal: why std::thread's destructor terminates, how jthread joins and requests stop, and the traps in stop_token/condition_variable_any.

Modern C++20 threading (jthread, stop tokens, latches, semaphores) from the author of "C++ Concurrency in Action".

atomic wait/notify, latch, barrier and counting_semaphore — how they work and when to pick each.

Short and concrete: splitting a Mandelbrot render across threads and measuring the speed-up, including what hyperthreading does and doesn't buy.

C++20 jthread (joins on destruction) and stop_token for cooperative cancellation.