Write Safe Concurrent C++ with Threads and Mutexes
Learn to launch threads, protect shared data with mutexes, and sidestep the classic pitfalls of concurrent C++ programs.
By the end of this page, you will know how to launch multiple threads in C++11, share data between them without corruption, coordinate work using std::mutex and std::lock_guard, and recognize the two most dangerous mistakes beginners make: data races and deadlocks.
What and Why
Modern CPUs have many cores sitting idle while a single-threaded program runs. Concurrency lets you put those cores to work simultaneously β shortening total runtime, keeping a UI responsive while a network call is in flight, or processing a queue of tasks in parallel.
C++ gained a standard threading model in C++11. Before that, every platform had its own API (pthreads on Linux, Win32 threads on Windows). Today <thread>, <mutex>, and related headers give you a portable, well-defined model for concurrent execution.
The central promise β and the central danger β of concurrency is shared mutable state. Two threads reading the same variable is fine. Two threads where at least one is writing to the same variable at the same time is a data race, which is undefined behaviour in C++. Your job as the programmer is to ensure that every write to shared data is protected so only one thread touches it at a time.
Step by Step
1. Launch a thread
std::thread takes any callable β a function, a lambda, a functor β and runs it concurrently.
#include <iostream>
#include <thread>
void greet(int id) {
std::cout << "Hello from thread " << id << "\n";
}
int main() {
std::thread t1(greet, 1);
std::thread t2(greet, 2);
t1.join(); // wait for t1 to finish
t2.join(); // wait for t2 to finish
}join() blocks the calling thread until the target thread completes. You must call either join() or detach() before a std::thread object is destroyed β failing to do so calls std::terminate().
2. Understand the race
Remove the mutex for a moment and see what goes wrong:
#include <iostream>
#include <thread>
int counter = 0; // shared, unprotected
void increment() {
for (int i = 0; i < 100'000; ++i)
++counter; // DATA RACE β undefined behaviour
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << counter << "\n"; // will NOT reliably print 200000
}++counter looks like one operation but compiles to three: read the value, add one, write it back. Two threads interleave those steps and silently lose increments. Run this enough times and you will see different answers.
3. Protect shared data with std::mutex
A mutex (mutual exclusion object) ensures only one thread executes a critical section at a time. std::lock_guard acquires the mutex on construction and releases it when it goes out of scope β no chance of forgetting to unlock.
#include <iostream>
#include <mutex>
#include <thread>
int counter = 0;
std::mutex mtx;
void increment() {
for (int i = 0; i < 100'000; ++i) {
std::lock_guard<std::mutex> lock(mtx);
++counter;
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::cout << counter << "\n"; // always 200000
}In C++17 you can drop the template argument: std::lock_guard lock(mtx);. Either spelling works β choose whichever your compiler standard supports.
4. Prefer std::unique_lock when you need flexibility
lock_guard is fast and simple but can only unlock when it destructs. std::unique_lock can be unlocked early and re-locked, which is required when working with condition variables.
#include <mutex>
#include <vector>
std::mutex mtx;
std::vector<int> shared_data;
void add_item(int value) {
std::unique_lock<std::mutex> lock(mtx);
shared_data.push_back(value);
lock.unlock(); // release early β no need to hold during the print
// ...do something that does not need the lock...
}Common Patterns
Pattern 1 β Parallel work on independent data
When threads genuinely do not share state, concurrency is effortless. Partition the input so each thread owns a separate slice:
#include <numeric>
#include <thread>
#include <vector>
long long partial_sum(const std::vector<int>& v, std::size_t begin, std::size_t end) {
long long total = 0;
for (auto i = begin; i < end; ++i) total += v[i];
return total;
}
int main() {
std::vector<int> data(1'000'000, 1);
long long result_a = 0, result_b = 0;
std::thread t1([&]{ result_a = partial_sum(data, 0, 500'000); });
std::thread t2([&]{ result_b = partial_sum(data, 500'000, 1'000'000); });
t1.join();
t2.join();
long long total = result_a + result_b; // safe: reads happen after join()
}Each thread writes to its own variable (result_a, result_b). The final read of both variables happens after join(), which acts as a happens-before barrier β no mutex needed.
Pattern 2 β A thread-safe task queue
A mutex-guarded queue is the backbone of many producer/consumer systems:
#include <mutex>
#include <optional>
#include <queue>
template<typename T>
class SafeQueue {
public:
void push(T value) {
std::lock_guard<std::mutex> lock(mtx_);
q_.push(std::move(value));
}
std::optional<T> try_pop() {
std::lock_guard<std::mutex> lock(mtx_);
if (q_.empty()) return std::nullopt;
T val = std::move(q_.front());
q_.pop();
return val;
}
private:
std::queue<T> q_;
std::mutex mtx_;
};Pattern 3 β One-time initialisation with std::call_once
When expensive initialisation must happen exactly once regardless of how many threads race to trigger it, use std::once_flag and std::call_once:
#include <mutex>
#include <string>
std::once_flag config_flag;
std::string config_value;
void ensure_config_loaded() {
std::call_once(config_flag, []{
config_value = "loaded_from_disk"; // runs exactly once
});
}What Can Go Wrong
Mistake 1 β Forgetting join() or detach()
void bad() {
std::thread t([]{ /* work */ });
// t goes out of scope here without join or detach β std::terminate()
}Fix: use RAII. In C++20, std::jthread joins automatically on destruction (see jthread reference). In C++11/14/17, join in a destructor or at the end of the owning scope.
Mistake 2 β Locking the wrong mutex (or two mutexes in the wrong order)
std::mutex a, b;
void thread1() { std::lock_guard la(a); std::lock_guard lb(b); }
void thread2() { std::lock_guard lb(b); std::lock_guard la(a); }
// thread1 holds a, waits for b; thread2 holds b, waits for a β DEADLOCKFix: always acquire multiple mutexes in the same order throughout the codebase, or use std::scoped_lock (C++17), which locks all of them atomically:
#include <mutex>
std::mutex a, b;
void safe_transfer() {
std::scoped_lock lock(a, b); // deadlock-free regardless of acquisition order
// ... operate on resources guarded by a and b ...
}Mistake 3 β Holding a lock too long
Locking around a slow operation (file I/O, network, std::cout) serialises all threads through that bottleneck. Keep critical sections as short as possible: do the computation outside the lock, then lock only for the final write.
Quick Reference
| Need | Tool | Header |
|---|---|---|
| Run code concurrently | std::thread | <thread> |
| Auto-join on destruction (C++20) | std::jthread | <thread> |
| Exclusive access, simple | std::lock_guard | <mutex> |
| Exclusive access, flexible | std::unique_lock | <mutex> |
| Lock two mutexes atomically (C++17) | std::scoped_lock | <mutex> |
| Run a callable once across all threads | std::call_once | <mutex> |
| Lock-free integer operations | std::atomic<T> | <atomic> |
| Async result / future value | std::async + std::future | <future> |
Compile flags: always pass -pthread on Linux/macOS and enable at least C++11: g++ -std=c++17 -pthread main.cpp.
What's Next
- Atomics β when you just need a thread-safe counter,
std::atomicbeats a mutex for performance. - async and future β
std::asynclets you run a function on a background thread and retrieve its return value viastd::future, avoiding manual thread management entirely. - Condition variables β coordinate threads that need to wait for a condition (e.g., "queue is non-empty") rather than busy-looping.
- jthread (C++20) β C++20's safer, self-joining thread that also supports cooperative cancellation via
std::stop_token. - Parallel execution policies β parallel execution policies for standard algorithms let you parallelise a
std::sortorstd::transformwith a single extra argument.
Thread-Local Storage
thread_local gives each thread its own private copy of a variable, eliminating synchronization overhead entirely. Unlike global or static variables shared across all threads, a thread_local variable is initialized independently for each thread that accesses it and destroyed when that thread exits. This is ideal for per-thread caches, error state, or random-number generators.
#include <iostream>
#include <thread>
thread_local int call_count = 0; // each thread has its own counter
void worker(int id) {
for (int i = 0; i < 3; ++i) {
++call_count; // no mutex needed β no sharing
}
std::cout << "Thread " << id << " called " << call_count << " times\n";
}
int main() {
std::thread t1(worker, 1);
std::thread t2(worker, 2);
t1.join();
t2.join();
// Both threads print "called 3 times" independently
}thread_local can be applied to variables at namespace scope, function scope, or as static class members. At function scope it combines static lifetime (initialized once per thread, persists across calls) with thread-local storage duration. A typical use case is a per-thread memory arena that avoids contending on a global allocator lock.
std::async and std::future
std::async launches a callable on a background thread and returns a std::future that carries the result back to the caller. This higher-level abstraction removes the need to manually manage std::thread lifetime, join calls, and result variables. The future's .get() call blocks until the background work completes and either returns the value or re-throws any exception the task threw.
#include <future>
#include <iostream>
#include <stdexcept>
#include <vector>
long long sum_range(int lo, int hi) {
long long acc = 0;
for (int i = lo; i < hi; ++i) acc += i;
return acc;
}
int main() {
// Launch two tasks concurrently
auto f1 = std::async(std::launch::async, sum_range, 0, 500'000);
auto f2 = std::async(std::launch::async, sum_range, 500'000, 1'000'000);
long long total = f1.get() + f2.get(); // blocks until both finish
std::cout << "Total: " << total << "\n"; // 499999500000
}std::launch::async forces a new thread; the default policy (std::launch::async | std::launch::deferred) lets the runtime decide. Always store the returned future β if you ignore it, .get() is called in the destructor synchronously, defeating the purpose. Exception propagation is one of std::async's most useful features: if the task throws, the exception is captured inside the future and re-thrown when .get() is called, even across thread boundaries.
auto f = std::async(std::launch::async, []{ throw std::runtime_error{"oops"}; });
try {
f.get(); // re-throws here
} catch (const std::runtime_error& e) {
std::cerr << "Caught: " << e.what() << "\n";
}Reader-Writer Locks
Most read-heavy workloads suffer unnecessarily from an exclusive mutex because readers do not actually conflict with each other. std::shared_mutex (C++17) allows any number of threads to hold a shared (read) lock simultaneously, while a writer requires an exclusive lock that blocks all other readers and writers. Use std::shared_lock for readers and std::unique_lock (or std::lock_guard) for writers.
#include <shared_mutex>
#include <string>
#include <unordered_map>
class ConfigStore {
public:
std::string get(const std::string& key) const {
std::shared_lock lock(mtx_); // multiple readers allowed
auto it = data_.find(key);
return (it != data_.end()) ? it->second : "";
}
void set(const std::string& key, std::string value) {
std::unique_lock lock(mtx_); // exclusive: blocks all others
data_[key] = std::move(value);
}
private:
mutable std::shared_mutex mtx_;
std::unordered_map<std::string, std::string> data_;
};The mutable keyword on the mutex is necessary because get() is a const member function but still needs to lock. Reader-writer locks are most effective when reads are frequent and writes are rare β the overhead of shared_mutex is slightly higher than a plain mutex, so do not use it blindly; profile first.
Condition Variables and Spurious Wakeups
A condition variable lets a thread sleep efficiently until another thread signals that something interesting happened. Without it, you would busy-loop or sleep in a polling loop, wasting CPU or adding latency. The two-argument form cv.wait(lock, predicate) is always the correct way to wait β it re-checks the predicate after each wakeup to handle spurious wakeups, which are real events where wait returns even though no notification was sent.
#include <condition_variable>
#include <mutex>
#include <queue>
#include <thread>
std::queue<int> work_queue;
std::mutex mtx;
std::condition_variable cv;
bool done = false;
void producer() {
for (int i = 0; i < 5; ++i) {
{
std::unique_lock lock(mtx);
work_queue.push(i);
}
cv.notify_one(); // wake one waiting consumer
}
{ std::unique_lock lock(mtx); done = true; }
cv.notify_all();
}
void consumer() {
std::unique_lock lock(mtx);
cv.wait(lock, []{ return !work_queue.empty() || done; });
while (!work_queue.empty()) {
int val = work_queue.front();
work_queue.pop();
lock.unlock();
// process val ...
lock.lock();
cv.wait(lock, []{ return !work_queue.empty() || done; });
}
}The predicate []{ return !work_queue.empty() || done; } acts as a loop guard. Without it, a spurious wakeup would cause the consumer to dequeue from an empty queue, corrupting state. std::condition_variable_any is a generalization that works with any lockable type (not just std::unique_lock<std::mutex>), including std::shared_lock β useful when readers wait on a shared mutex.
Deadlock Avoidance
Deadlock occurs when two or more threads each hold a resource the other needs, causing all of them to wait forever. The classic scenario is two threads acquiring two mutexes in opposite orders. Three strategies prevent it: always acquire multiple mutexes in a globally consistent order, use std::scoped_lock to atomically lock all of them at once, or use std::lock followed by std::adopt_lock.
#include <mutex>
std::mutex account_a_mtx;
std::mutex account_b_mtx;
// DANGEROUS β opposite acquisition order in two threads causes deadlock
// thread1: lock(account_a_mtx), then lock(account_b_mtx)
// thread2: lock(account_b_mtx), then lock(account_a_mtx)
// SAFE β C++17 std::scoped_lock acquires all atomically
void transfer(int amount) {
std::scoped_lock lock(account_a_mtx, account_b_mtx);
// both locks held; operates safely on both accounts
}
// SAFE β C++11/14 equivalent using std::lock + adopt_lock
void transfer_cpp11(int amount) {
std::lock(account_a_mtx, account_b_mtx); // atomically locks both
std::lock_guard<std::mutex> la(account_a_mtx, std::adopt_lock);
std::lock_guard<std::mutex> lb(account_b_mtx, std::adopt_lock);
// RAII now owns both locks for release
}std::scoped_lock is the C++17 preferred solution β it accepts any number of lockable objects and uses a deadlock-avoidance algorithm (similar to std::lock) internally. Prefer it over manually ordering lock acquisition whenever you must hold multiple mutexes simultaneously.
Atomic Operations Beyond std::atomic<bool>
std::atomic<T> supports rich operations beyond simple load and store β arithmetic, bitwise, and compare-and-swap β all guaranteed to be indivisible from the perspective of other threads. fetch_add atomically adds a value and returns the old value, making it perfect for shared counters and statistics collection without any mutex overhead.
#include <atomic>
#include <thread>
#include <vector>
std::atomic<int> hit_count{0};
std::atomic<long long> total_latency{0};
void record_request(int latency_us) {
hit_count.fetch_add(1, std::memory_order_relaxed);
total_latency.fetch_add(latency_us, std::memory_order_relaxed);
}Compare-and-swap (CAS) is the foundation of lock-free data structures. compare_exchange_weak atomically checks if the value equals expected and, if so, replaces it with desired; it returns true on success. The "weak" variant may spuriously fail (returning false even when values match) β always use it in a retry loop for this reason:
#include <atomic>
std::atomic<int> value{0};
void increment_if_positive() {
int old = value.load(std::memory_order_relaxed);
int desired;
do {
if (old <= 0) return; // bail out if not positive
desired = old + 1;
} while (!value.compare_exchange_weak(
old, desired,
std::memory_order_acq_rel,
std::memory_order_relaxed));
// if CAS fails, old is updated to the current value; loop retries
}compare_exchange_strong does not spuriously fail β prefer it when the retry loop body is expensive, and compare_exchange_weak when it compiles to a tighter loop on architectures like ARM.
The volatile Keyword
volatile tells the compiler that a variable may change through means outside its view β hardware registers, memory-mapped I/O, signal handlers β and it must not cache or reorder accesses to it. This is its only guarantee. It does NOT prevent reordering by the CPU, does NOT provide atomicity, and is NOT a substitute for std::atomic in multithreaded code.
// Hardware register β correct use of volatile
volatile uint32_t* const STATUS_REG =
reinterpret_cast<volatile uint32_t*>(0x4000'0000);
void wait_for_device_ready() {
while (!(*STATUS_REG & 0x01)) {} // must read register each iteration
}
// WRONG β volatile does not protect against data races
volatile int counter = 0;
void bad_increment() {
++counter; // still a data race: read-modify-write is not atomic
}
// CORRECT β use std::atomic
std::atomic<int> safe_counter{0};
void good_increment() {
safe_counter.fetch_add(1, std::memory_order_relaxed);
}In practice, volatile belongs in two places: device drivers interacting with memory-mapped hardware registers, and signal handlers sharing a flag with the main program (where volatile sig_atomic_t is the POSIX-blessed type). Everywhere else in concurrent C++ code, reach for std::atomic instead.