Skip to main content
C++ advanced Lesson 17 of 23

Concurrency in C++

Write multi-threaded programs using std::thread, std::mutex, std::atomic, and std::future for safe parallel execution.

C++ has had a standardized memory model and threading library since C++11. Rather than relying on platform-specific APIs (pthreads, Win32 threads), you can write portable concurrent code entirely in standard C++. Getting it right requires understanding ownership, synchronization primitives, and where undefined behavior lurks. A data race — two threads accessing the same memory with at least one write and no synchronization — is undefined behavior in C++, not just a logical bug.

std::thread — Creating Threads

std::thread launches a function on a new OS thread. Every thread you create must be either joined (you wait for it) or detached (it runs independently) before the std::thread object is destroyed — failing to do either calls std::terminate. Joining is almost always the right choice because it lets you observe the result and ensures the thread completes before resources are cleaned up.

#include <thread>
#include <iostream>

void worker(int id, int iterations) {
    for (int i = 0; i < iterations; ++i) {
        std::cout << "thread " << id << " step " << i << "\n";
    }
}

int main() {
    std::thread t1(worker, 1, 5);
    std::thread t2(worker, 2, 5);

    t1.join();  // wait for t1 to finish — blocks until done
    t2.join();  // wait for t2 to finish
    // If you neither join nor detach before the thread object is destroyed,
    // std::terminate() is called — always join or detach.
}

detach() lets a thread run independently. The thread outlives the std::thread object, but you lose the ability to wait for it or observe its result. Prefer join() for work you need to synchronize with.

std::mutex and std::lock_guard

A data race — two threads accessing the same data concurrently with at least one write and no synchronization — is undefined behavior. A mutex serializes access so only one thread is in the critical section at a time. std::lock_guard is RAII for mutexes: it locks in the constructor and unlocks in the destructor, which means the lock is released even if an exception is thrown.

#include <mutex>
#include <thread>
#include <vector>
#include <iostream>

std::mutex mtx;
int shared_counter = 0;

void increment(int n) {
    for (int i = 0; i < n; ++i) {
        std::lock_guard<std::mutex> lock(mtx); // RAII: locks on construction
        ++shared_counter;                        // only one thread here at a time
    }                                            // unlocks on destruction
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 8; ++i)
        threads.emplace_back(increment, 100000);
    for (auto& t : threads) t.join();
    std::cout << shared_counter << "\n"; // always 800000 — correct
}

Use std::unique_lock when you need more flexibility: deferred locking, early unlocking, or condition variables.

std::mutex mtx;

void conditional_work(bool do_work) {
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // don't lock yet
    if (do_work) {
        lock.lock();
        // ... critical section ...
        lock.unlock(); // can unlock early if needed — lock_guard cannot
    }
}

std::condition_variable — Thread Coordination

Condition variables let threads wait until a condition is true, avoiding busy-waiting (repeatedly checking a flag in a loop, which wastes CPU). The waiting thread releases the mutex and sleeps until another thread signals it. The predicate passed to wait guards against spurious wakeups — threads can wake without being notified, and the predicate ensures they go back to sleep if the condition isn’t actually met.

#include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>

template<typename T>
class BoundedQueue {
    std::queue<T> queue_;
    std::mutex mtx_;
    std::condition_variable not_empty_;
    std::condition_variable not_full_;
    std::size_t capacity_;

public:
    explicit BoundedQueue(std::size_t cap) : capacity_(cap) {}

    void push(T item) {
        std::unique_lock lock(mtx_);
        // Wait until there is space — predicate guards against spurious wakeups
        not_full_.wait(lock, [this] { return queue_.size() < capacity_; });
        queue_.push(std::move(item));
        not_empty_.notify_one();  // wake one waiting consumer
    }

    T pop() {
        std::unique_lock lock(mtx_);
        not_empty_.wait(lock, [this] { return !queue_.empty(); });
        T item = std::move(queue_.front());
        queue_.pop();
        not_full_.notify_one();  // wake one waiting producer
        return item;
    }
};

std::atomic — Lock-Free Operations

std::atomic<T> provides indivisible read-modify-write operations without a mutex. It is the right tool for counters, flags, and simple state machines where the overhead of a mutex would be significant. Atomics are typically implemented with CPU lock instructions or compare-and-swap loops, making them much cheaper than a mutex for simple operations.

#include <atomic>
#include <thread>
#include <vector>

std::atomic<int> counter{0};
std::atomic<bool> stop_flag{false};

void count_up(int n) {
    for (int i = 0; i < n; ++i)
        counter.fetch_add(1, std::memory_order_relaxed); // fastest for pure counting
}

// Compare-and-swap: the foundation of lock-free algorithms
// Only the one thread that sees false and sets it to true wins
bool claim_once(std::atomic<bool>& flag) {
    bool expected = false;
    return flag.compare_exchange_strong(expected, true);
}

Memory Orders

Memory orders control how atomic operations synchronize with other threads. The default (seq_cst) is the safest — it provides a total global ordering of all atomic operations. Weaker orders are faster but harder to reason about correctly.

OrderGuarantee
relaxedNo ordering, just atomicity. For counters that don’t synchronize other data.
acquireReads see all writes that happened before the corresponding release.
releaseWrites are visible to threads that subsequently acquire.
seq_cstTotal sequential ordering across all threads. Default, and the safest.
std::atomic<bool> ready{false};
int data = 0;

void producer() {
    data = 42;                                      // must be visible before ready
    ready.store(true, std::memory_order_release);   // publishes: all prior writes visible
}

void consumer() {
    while (!ready.load(std::memory_order_acquire)); // waits for producer's release
    assert(data == 42);                             // guaranteed to see 42
}

std::future, std::promise, and std::async

These provide a higher-level model for one-shot communication: one thread produces a value, another consumes it. std::async is the simplest entry point — it launches work on a new thread (or deferred) and returns a future that holds the result.

#include <future>
#include <numeric>
#include <vector>

// std::async launches work asynchronously and returns a future for the result
std::future<double> compute = std::async(std::launch::async, []() {
    std::vector<int> v(1'000'000);
    std::iota(v.begin(), v.end(), 1);
    return std::accumulate(v.begin(), v.end(), 0LL) / double(v.size());
});

// Do other work here while compute runs in the background...

double result = compute.get(); // blocks until the result is ready
std::cout << result << "\n";

std::promise / std::future are the lower-level building blocks when you need manual control over when the value is set:

std::promise<int> prom;
std::future<int> fut = prom.get_future();

std::thread t([&prom]() {
    // ... do work ...
    prom.set_value(42); // signals the future — unblocks fut.get()
});

int val = fut.get(); // waits for set_value
t.join();

std::packaged_task wraps a callable so its return value populates a future — useful for thread pools.

std::jthread (C++20)

std::jthread improves on std::thread in two ways: it joins automatically in its destructor (so you can’t accidentally call std::terminate), and it supports cooperative cancellation via std::stop_token. This makes it the right choice for worker threads in C++20 code.

#include <thread>
#include <stop_token>
#include <chrono>
#include <iostream>

int main() {
    std::jthread worker([](std::stop_token stoken) {
        while (!stoken.stop_requested()) {
            std::cout << "working...\n";
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        }
        std::cout << "stopping cleanly\n";
    });

    std::this_thread::sleep_for(std::chrono::milliseconds(350));
    worker.request_stop(); // signal the thread to stop cooperatively
    // worker.join() is called automatically in destructor — no std::terminate risk
}

Thread Pool Pattern

Launching a new thread for every task is expensive. A thread pool creates a fixed number of worker threads once and distributes tasks among them via a shared queue. This amortizes thread creation cost and bounds the total number of OS threads.

#include <thread>
#include <mutex>
#include <condition_variable>
#include <queue>
#include <functional>
#include <vector>

class ThreadPool {
    std::vector<std::thread> workers_;
    std::queue<std::function<void()>> tasks_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool stop_ = false;

public:
    explicit ThreadPool(std::size_t n) {
        for (std::size_t i = 0; i < n; ++i) {
            workers_.emplace_back([this] {
                for (;;) {
                    std::function<void()> task;
                    {
                        std::unique_lock lock(mtx_);
                        cv_.wait(lock, [this] { return stop_ || !tasks_.empty(); });
                        if (stop_ && tasks_.empty()) return;  // drain then exit
                        task = std::move(tasks_.front());
                        tasks_.pop();
                    }
                    task();  // execute outside the lock
                }
            });
        }
    }

    template<typename F>
    void submit(F&& f) {
        {
            std::lock_guard lock(mtx_);
            tasks_.emplace(std::forward<F>(f));
        }
        cv_.notify_one();  // wake one idle worker
    }

    ~ThreadPool() {
        { std::lock_guard lock(mtx_); stop_ = true; }
        cv_.notify_all();  // wake all workers so they can exit
        for (auto& w : workers_) w.join();
    }
};

Thread-Local Storage

thread_local gives each thread its own independent copy of a variable. This eliminates the need for a mutex when each thread only needs its own state — a common pattern for per-thread caches, random number generators, and error state.

// Each thread gets its own seeded RNG — no mutex needed, no contention
thread_local std::mt19937 rng{std::random_device{}()};

Common Concurrency Bugs

Deadlock: two threads each hold a mutex the other needs. Avoid by always locking multiple mutexes in the same order, or use std::scoped_lock (C++17) which locks all atomically:

std::mutex m1, m2;
// Safe: locks both atomically, avoids deadlock even if another thread locks in reverse order
std::scoped_lock lock(m1, m2);

Race condition: logic that assumes a particular thread interleaving. Even with no data race, reading-then-writing a counter in two separate atomic operations can be wrong if another thread acts between them — use atomic fetch-and-add instead.

False sharing: two threads writing to different variables that happen to share a cache line causes cache invalidation traffic and severe slowdown. Fix with alignas(64) to place hot variables on separate cache lines.

Frequently Asked Questions

When should I use atomic vs mutex?
Use atomic for simple scalar operations (counters, flags). Use mutex when you need to protect multi-step operations or complex data structures.
What is a data race?
When two threads access the same memory location concurrently and at least one is a write, without synchronization. It's undefined behavior in C++.