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:
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.
#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();
}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:
#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
}#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 |
#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
}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.
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.
#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:
std::unique_lock lock(m);
cv.wait(lock, [&] { return ready; }); // returns only when ready is trueready 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:
#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.
#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:
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) |
#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:
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_bslow_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.
#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::refdeliberately, 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::futurecarry results and exceptions between threads;std::async's future blocks on destruction.- Run everything under ThreadSanitizer.