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.
| Order | Guarantee |
|---|---|
relaxed | No ordering, just atomicity. For counters that don’t synchronize other data. |
acquire | Reads see all writes that happened before the corresponding release. |
release | Writes are visible to threads that subsequently acquire. |
seq_cst | Total 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.