Concurrency: threads, mutexes and async
Start threads safely, guard shared state, use condition variables and atomics, and know what a data race actually costs.
Threads and RAII locking
#include <thread>
#include <mutex>
#include <vector>
#include <numeric>
#include <atomic>
std::mutex mtx;
long total = 0; // guarded by mtx
void add_range(const std::vector<int>& v, std::size_t lo, std::size_t hi) {
long local = 0;
for (std::size_t i = lo; i < hi; ++i) local += v[i];
std::lock_guard<std::mutex> lock(mtx); // protects just the update
total += local;
}
int main() {
std::vector<int> v(1'000'000, 1);
std::vector<std::thread> pool;
const std::size_t chunk = v.size() / 4;
for (int t = 0; t < 4; ++t)
pool.emplace_back(add_range, std::cref(v), t * chunk,
(t == 3) ? v.size() : (t + 1) * chunk);
for (auto& th : pool) th.join(); // join every thread before exit
// std::jthread (C++20) joins in its destructor and supports stop tokens
{
std::jthread worker([]{ /* ... */ });
// worker.stop_requested() and worker.get_stop_token()
}
}- A
std::threadthat is neither joined nor detached callsstd::terminatein its destructor.std::jthreadfixes this by joining automatically. - Use
std::lock_guardfor a scoped lock andstd::unique_lockwhen you must unlock early or hand the lock to a condition variable. std::scoped_locklocks several mutexes together without deadlock, replacing the manualstd::lockplusstd::adopt_lockdance.- Never hold a lock while calling a callback you do not control; if it takes another lock, you have a deadlock waiting for exactly the wrong timing.
// the classic deadlock, and the fix
void bad(A& a, B& b) {
std::lock_guard<std::mutex> la(a.m);
std::lock_guard<std::mutex> lb(b.m); // deadlocks against the opposite order
}
void good(A& a, B& b) {
std::scoped_lock lock(a.m, b.m); // deadlock-free: locks in a fixed internal order
}Condition variables and atomics
#include <condition_variable>
#include <mutex>
#include <queue>
#include <optional>
template <class T>
class BoundedQueue {
public:
explicit BoundedQueue(std::size_t cap) : cap_(cap) {}
void push(T value) {
std::unique_lock<std::mutex> lk(m_);
not_full_.wait(lk, [this] { return q_.size() < cap_ || closed_; });
if (closed_) return;
q_.push(std::move(value));
not_empty_.notify_one();
}
std::optional<T> pop() {
std::unique_lock<std::mutex> lk(m_);
not_empty_.wait(lk, [this] { return !q_.empty() || closed_; });
if (q_.empty()) return std::nullopt;
T value = std::move(q_.front());
q_.pop();
not_full_.notify_one();
return value;
}
void close() {
{ std::lock_guard<std::mutex> lk(m_); closed_ = true; }
not_empty_.notify_all(); // wake every waiter
not_full_.notify_all();
}
private:
std::queue<T> q_;
std::size_t cap_;
bool closed_ = false;
std::mutex m_;
std::condition_variable not_full_, not_empty_;
};| Primitive | Guarantee | Cost |
|---|---|---|
std::mutex | Mutual exclusion, OS-backed | Blocking, may sleep |
std::atomic<T> | Indivisible operation, lock-free for small types | Fast, but only one variable at a time |
std::condition_variable | Wait for a predicate, releases the mutex while waiting | Needs a mutex and a correct predicate |
std::shared_mutex | Many readers or one writer | Reader-writer lock, heavier than a plain mutex |
std::once_flag | Run an initialisation exactly once | The correct lazy-init primitive |
Always wait with a predicate. A condition variable may wake spuriously and may be woken between the notification and your acquisition of the lock, so wait(lock) alone is not sufficient — wait(lock, pred) re-checks and re-waits correctly.
async, futures and memory ordering
#include <future>
#include <chrono>
#include <numeric>
#include <vector>
int work(int n) { return n * n; }
int main() {
// std::async may or may not run on another thread: check the policy
std::future<int> f = std::async(std::launch::async, work, 7);
std::cout << f.get() << '\n'; // get() blocks and may only be called once
// wait with a timeout, then give up
if (f.wait_for(std::chrono::seconds(1)) == std::future_status::timeout) return 1;
std::packaged_task<int(int)> pt(work);
std::future<int> pf = pt.get_future();
std::thread t(std::move(pt), 5);
t.join();
std::cout << pf.get() << '\n';
// memory ordering: relaxed for counters, acquire/release for handoff
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);
}std::asyncwith no policy is allowed to run deferred on the calling thread, which makes it useless for parallelism. Passstd::launch::asyncexplicitly.- A future from
std::asyncblocks in its destructor, which is a hidden synchronisation point. fetch_addwithmemory_order_relaxedis fine for a statistics counter and wrong for publishing data to another thread, which needs release/acquire.- A data race is undefined behaviour, not a wrong value: the compiler may cache the value in a register, reorder the accesses, or optimise the check away entirely.
⚠️
Correct locking is a design property, not a set of defensive calls sprinkled through the code. Write down which mutex protects which data, keep that invariant in comments next to the member declarations, and build and test with ThreadSanitizer in CI.
FAQ
Is a single atomic write enough to make my code thread safe?
No. Atomically updating one variable is safe; keeping two variables consistent is not. If an invariant spans more than one object, you need a lock or a different data layout such as a single atomic pointer to an immutable snapshot.
Why does my program hang in a destructor?
A common cause is calling
join on a thread that is blocked waiting on the same mutex the destructor holds, or waiting for a future from a task that can never finish because its thread pool has been stopped.Related
The standard library and smart pointers Testing, sanitizers and profiling
Last refreshed 2026-09-18.