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::thread that is neither joined nor detached calls std::terminate in its destructor. std::jthread fixes this by joining automatically.
  • Use std::lock_guard for a scoped lock and std::unique_lock when you must unlock early or hand the lock to a condition variable.
  • std::scoped_lock locks several mutexes together without deadlock, replacing the manual std::lock plus std::adopt_lock dance.
  • 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_;
};
PrimitiveGuaranteeCost
std::mutexMutual exclusion, OS-backedBlocking, may sleep
std::atomic<T>Indivisible operation, lock-free for small typesFast, but only one variable at a time
std::condition_variableWait for a predicate, releases the mutex while waitingNeeds a mutex and a correct predicate
std::shared_mutexMany readers or one writerReader-writer lock, heavier than a plain mutex
std::once_flagRun an initialisation exactly onceThe 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::async with no policy is allowed to run deferred on the calling thread, which makes it useless for parallelism. Pass std::launch::async explicitly.
  • A future from std::async blocks in its destructor, which is a hidden synchronisation point.
  • fetch_add with memory_order_relaxed is 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.

The standard library and smart pointers Testing, sanitizers and profiling

Last refreshed 2026-09-18.