std::mutex, lock_guard, unique_lock, scoped_lock, condition_variable
C++ mutual exclusion primitives — mutex variants, RAII lock guards, condition variables, and correct patterns for thread-safe code.
std::mutexsince C++11A non-recursive, non-transferable mutual exclusion primitive that serializes concurrent access to shared state; always acquired and released through RAII wrappers, never by raw lock()/unlock() calls.
Overview
<mutex> (C++11) defines the mutex family, RAII wrappers, and deadlock-avoidance utilities. <shared_mutex> (C++14/17) adds reader-writer variants. The cost of acquiring an uncontended mutex is typically 20–100 ns; std::atomic operations run 1–5 ns. For isolated counters and flags, prefer std::atomic — reach for mutex only when you need to protect a multi-step invariant.
#include <mutex>
std::mutex mtx;
int counter = 0;
void increment() {
std::lock_guard lock(mtx); // C++17: CTAD; C++11: lock_guard<std::mutex>
++counter;
}Mutex variants
| Type | Recursive? | Timed? | Shared lock? | Since |
|---|---|---|---|---|
std::mutex | ❌ | ❌ | ❌ | C++11 |
std::recursive_mutex | ✅ | ❌ | ❌ | C++11 |
std::timed_mutex | ❌ | ✅ | ❌ | C++11 |
std::recursive_timed_mutex | ✅ | ✅ | ❌ | C++11 |
std::shared_timed_mutex | ❌ | ✅ | ✅ | C++14 |
std::shared_mutex | ❌ | ❌ | ✅ | C++17 |
Prefer std::shared_mutex (C++17) over std::shared_timed_mutex (C++14) when timeout-based acquisition is unnecessary — the leaner interface permits tighter implementations.
std::recursive_mutex is a design smell. It exists for callback-heavy APIs where re-entrant locking is unavoidable; in new code, restructure first.
RAII lock wrappers
lock_guard — simplest, fixed scope (C++11)
Non-copyable, non-movable. Prefer it for the common case: one mutex, one scope.
void append(std::vector<int>& v, int x) {
std::lock_guard<std::mutex> guard(mtx); // C++11
v.push_back(x);
}
// C++17: CTAD eliminates the template argument
std::lock_guard guard(mtx);scoped_lock — multiple mutexes, deadlock-free (C++17)
Acquires an arbitrary number of mutexes atomically using a deadlock-avoidance algorithm. Supersedes the std::lock + adopt_lock dance from C++11 for all multi-mutex scenarios.
std::mutex m1, m2;
void safe_swap(Resource& a, Resource& b) {
std::scoped_lock lock(a.mtx, b.mtx); // C++17 — order-independent
std::swap(a.data, b.data);
}
// C++11 equivalent — error-prone but sometimes necessary
void safe_swap_11(Resource& a, Resource& b) {
std::lock(a.mtx, b.mtx);
std::lock_guard<std::mutex> la(a.mtx, std::adopt_lock);
std::lock_guard<std::mutex> lb(b.mtx, std::adopt_lock);
std::swap(a.data, b.data);
}unique_lock — movable, unlockable mid-scope (C++11)
Required by condition_variable::wait. Stores an owns_lock boolean flag, making it marginally heavier than lock_guard. Supports deferred, try, and timed acquisition.
void process(Cache& cache) {
std::unique_lock lock(mtx);
auto snapshot = cache.snapshot(); // fast copy under lock
lock.unlock();
auto result = compute(snapshot); // slow work outside lock
lock.lock();
cache.commit(result);
}
// Deferred: construct unlocked, lock when ready
std::unique_lock lock(mtx, std::defer_lock);
// ... other state setup ...
lock.lock();
// Try-lock: non-blocking
std::unique_lock trylock(mtx, std::try_to_lock);
if (trylock.owns_lock()) { /* acquired */ }
// Timed: C++11, requires timed_mutex
std::unique_lock timedlock(timed_mtx, std::chrono::milliseconds(50));
if (timedlock.owns_lock()) { /* acquired within deadline */ }shared_lock — shared (read) lock (C++14)
Used with shared_mutex to allow concurrent reads while serializing writes.
#include <shared_mutex>
class Config {
mutable std::shared_mutex mtx_; // C++17
std::unordered_map<std::string, std::string> data_;
public:
std::string get(const std::string& key) const {
std::shared_lock lock(mtx_); // C++14 — multiple readers concurrent
auto it = data_.find(key);
return it != data_.end() ? it->second : "";
}
void set(const std::string& key, std::string val) {
std::unique_lock lock(mtx_); // exclusive write
data_.insert_or_assign(key, std::move(val));
}
};Reader-writer locks only pay off when reads are frequent and significantly longer than lock acquisition, and write contention is low. Benchmark before adopting — a plain std::mutex frequently wins due to smaller code size and better cache behavior.
condition_variable
std::condition_variable (C++11) enables threads to block until a condition becomes true. It requires unique_lock<mutex>. Always supply a predicate — spurious wakeups are guaranteed by the standard to occur.
#include <condition_variable>
#include <queue>
template<typename T>
class BlockingQueue {
std::mutex mtx_;
std::condition_variable cv_;
std::queue<T> q_;
bool closed_ = false;
public:
void push(T val) {
{
std::lock_guard lock(mtx_);
q_.push(std::move(val));
}
cv_.notify_one();
}
void close() {
{
std::lock_guard lock(mtx_);
closed_ = true;
}
cv_.notify_all();
}
bool pop(T& out) {
std::unique_lock lock(mtx_);
cv_.wait(lock, [&] { return !q_.empty() || closed_; });
if (q_.empty()) return false;
out = std::move(q_.front());
q_.pop();
return true;
}
};cv.wait(lock, pred) is strictly equivalent to while (!pred()) cv.wait(lock) — the predicate is rechecked after every wakeup.
std::condition_variable_any (C++11) accepts any BasicLockable, not just unique_lock<mutex>. In C++20 it gains overloads that accept a std::stop_token for cooperative cancellation:
// C++20: interruptible wait
std::condition_variable_any cv;
std::shared_mutex smtx;
// Returns early if stop_token fires; check stop_token.stop_requested() on return
cv.wait(std::shared_lock{smtx}, stop_token, predicate);call_once and once_flag
std::call_once (C++11) executes an initializer exactly once even under concurrent invocation, with lower overhead than a checked mutex.
#include <mutex>
class HeavyResource {
std::once_flag flag_;
std::unique_ptr<Backend> backend_;
public:
Backend& get() {
std::call_once(flag_, [&] {
backend_ = std::make_unique<Backend>("production");
});
return *backend_;
}
};For static-duration singletons, C++11 guarantees thread-safe initialization of function-local statics — no once_flag needed:
Logger& logger() {
static Logger instance; // C++11: initialization is thread-safe
return instance;
}Common Pitfalls
Unnamed temporary — destroyed immediately
// BUG: anonymous temporary; destroyed at the semicolon — lock held for zero time
std::lock_guard<std::mutex>(mtx);
// Fix: name it
std::lock_guard lock(mtx);Condition variable without predicate
cv.wait(lock); // WRONG: spurious wakeup exits even if condition is false
cv.wait(lock, [&] { return !queue_.empty() || done_; }); // CORRECTProtecting only half a transfer
struct Account {
std::mutex mtx;
int balance;
void transfer_to(Account& dst, int amount) {
// BAD: dst.mtx unheld; concurrent reverse transfer is a data race
std::lock_guard lock(mtx);
balance -= amount;
dst.balance += amount;
// GOOD: both mutexes, deadlock-free
std::scoped_lock both(mtx, dst.mtx); // C++17
balance -= amount;
dst.balance += amount;
}
};Holding the lock across slow operations
Copy shared data under the lock; do the slow work outside it.
// BAD: holds lock during blocking network call
{
std::lock_guard lock(mtx);
send_over_network(shared_data_); // may block for seconds
}
// GOOD: narrow critical section
Data snapshot;
{
std::lock_guard lock(mtx);
snapshot = shared_data_;
}
send_over_network(snapshot);Deadlock prevention
- Consistent acquisition order — always lock
m1beforem2across all code paths. std::scoped_lock(C++17) — acquire multiple locks in one statement; deadlock impossible.- Lock hierarchies — assign numeric levels; a thread holding level N may only acquire level < N next.
- Avoid nested locks — one mutex at a time unless
scoped_lockcovers all of them.
std::scoped_lock for multiple mutexes (C++17)
std::scoped_lock is the correct tool whenever a function must hold more than one mutex simultaneously. It accepts a variadic list of lockable objects and acquires them using a deadlock-avoidance algorithm equivalent to std::lock, then releases all of them in reverse order on destruction.
// C++17
#include <mutex>
#include <string>
struct BankAccount {
std::mutex mtx;
double balance;
std::string owner;
};
void transfer(BankAccount& from, BankAccount& to, double amount) {
// Acquires both mutexes atomically — no deadlock regardless of call order
std::scoped_lock lock(from.mtx, to.mtx); // C++17
if (from.balance >= amount) {
from.balance -= amount;
to.balance += amount;
}
}
// Works correctly even when two threads call:
// transfer(alice, bob, 100); (thread 1)
// transfer(bob, alice, 50); (thread 2)When locking a single mutex, std::scoped_lock degenerates to std::lock_guard — zero overhead. There is no reason to reach for lock_guard unless you need to name the wrapper type explicitly in a template.
// C++17: single-mutex usage is identical to lock_guard
std::scoped_lock one(mtx); // fine
std::scoped_lock zero(); // zero-mutex: valid, no-op lock/unlockstd::timed_mutex and try_lock_for
Timed mutexes allow a thread to back off rather than block indefinitely, which is useful when forward progress must be guaranteed. std::timed_mutex adds try_lock_for (relative duration) and try_lock_until (absolute time point) on top of the plain mutex interface.
// C++11
#include <mutex>
#include <chrono>
#include <iostream>
std::timed_mutex resource_mtx;
bool do_work_with_timeout() {
// Give up if the lock is not available within 100 ms
std::unique_lock lock(resource_mtx,
std::chrono::milliseconds(100)); // C++11
if (!lock.owns_lock()) {
std::cerr << "Could not acquire lock within deadline\n";
return false;
}
// ... protected work ...
return true;
}
// Polling variant: try without blocking at all
bool try_once() {
std::unique_lock lock(resource_mtx, std::try_to_lock); // C++11
if (!lock.owns_lock()) return false;
// ... protected work ...
return true;
}Timed acquisition does not eliminate the possibility of starvation — a thread that perpetually fails to acquire the lock within its budget will retry forever unless the caller applies an upper bound on total attempts.
Reader-writer mutex pattern
std::shared_mutex (C++17) lets any number of readers hold the lock concurrently while writers get exclusive access. This pattern pays off when reads dominate, each read is non-trivial (cache lookup, serialization), and write contention is low.
// C++17
#include <shared_mutex>
#include <unordered_map>
#include <optional>
class ThreadSafeCache {
mutable std::shared_mutex mtx_;
std::unordered_map<int, std::string> map_;
public:
// Many threads may execute get() simultaneously
std::optional<std::string> get(int key) const {
std::shared_lock lock(mtx_); // C++14 — shared (read) lock
auto it = map_.find(key);
if (it == map_.end()) return std::nullopt;
return it->second;
}
// put() and erase() serialize against each other and against readers
void put(int key, std::string val) {
std::unique_lock lock(mtx_); // C++11 — exclusive (write) lock
map_.insert_or_assign(key, std::move(val));
}
void erase(int key) {
std::unique_lock lock(mtx_); // C++11
map_.erase(key);
}
};If writes are as frequent as reads, a std::shared_mutex often performs worse than a plain std::mutex because the internal bookkeeping (reader count, writer-pending flag) introduces extra cache traffic. Always benchmark with your actual access pattern before committing to the reader-writer model.
Lock-free alternatives: when to use them
Lock-free code avoids mutex overhead but trades it for stricter design constraints — use it only when profiling identifies mutex contention as a real bottleneck. std::atomic<T> provides lock-free operations on scalars and trivially copyable types small enough for the hardware to update atomically.
// C++11 — prefer atomic over mutex for simple counters and flags
#include <atomic>
std::atomic<int> request_count{0}; // lock-free on every mainstream CPU
std::atomic<bool> shutdown_flag{false};
void worker() {
while (!shutdown_flag.load(std::memory_order_acquire)) {
// process one request
request_count.fetch_add(1, std::memory_order_relaxed);
}
}
void supervisor() {
shutdown_flag.store(true, std::memory_order_release);
}// C++20: atomic<std::shared_ptr<T>> — lock-free reference-counted snapshot
#include <atomic>
#include <memory>
struct Config { int timeout; int retries; };
std::atomic<std::shared_ptr<Config>> g_config;
// Reader: O(1), no lock
std::shared_ptr<Config> current = g_config.load();
// Writer: replace atomically — readers always see a consistent object
g_config.store(std::make_shared<Config>(Config{30, 3})); // C++20Reach for lock-free only when the data fits std::atomic's constraints. Multi-field structs and operations that must span two separate objects still need a mutex — attempting lock-free designs in those cases produces subtle data races.
Mutex and condition_variable: producer-consumer
The producer-consumer pattern is the canonical use case for condition_variable: producers push work onto a shared queue and notify, consumers block until work arrives. The predicate passed to wait guards against both spurious wakeups and the lost-wakeup race (notification arriving before wait is called).
// C++11
#include <mutex>
#include <condition_variable>
#include <queue>
#include <thread>
#include <optional>
template<typename T>
class Channel {
std::mutex mtx_;
std::condition_variable not_empty_;
std::condition_variable not_full_;
std::queue<T> q_;
const std::size_t cap_;
bool closed_ = false;
public:
explicit Channel(std::size_t capacity = 64) : cap_(capacity) {}
// Producer: blocks when the queue is full
bool send(T value) {
std::unique_lock lock(mtx_);
not_full_.wait(lock, [&] { return q_.size() < cap_ || closed_; });
if (closed_) return false;
q_.push(std::move(value));
not_empty_.notify_one();
return true;
}
// Consumer: blocks when the queue is empty
std::optional<T> recv() {
std::unique_lock lock(mtx_);
not_empty_.wait(lock, [&] { return !q_.empty() || closed_; });
if (q_.empty()) return std::nullopt; // channel closed and drained
T val = std::move(q_.front());
q_.pop();
not_full_.notify_one();
return val;
}
void close() {
std::lock_guard lock(mtx_);
closed_ = true;
not_empty_.notify_all();
not_full_.notify_all();
}
};
// Usage
Channel<int> ch(8);
std::thread producer([&] {
for (int i = 0; i < 100; ++i) ch.send(i);
ch.close();
});
std::thread consumer([&] {
while (auto v = ch.recv()) process(*v);
});
producer.join();
consumer.join();Always call notify_one / notify_all after releasing the lock when possible — this avoids waking a waiter only to have it re-block immediately on the still-held mutex.
Common deadlock patterns and avoidance
Most deadlocks arise from one of three patterns: lock-order inversion, self-deadlock on a non-recursive mutex, and forgetting that a callback can re-enter the calling code while a mutex is held.
Lock-order inversion
// C++11 — DEADLOCK: thread A locks m1 then m2; thread B locks m2 then m1
std::mutex m1, m2;
// Thread A
std::lock_guard la1(m1);
std::lock_guard la2(m2); // blocks if thread B holds m2
// Thread B
std::lock_guard lb1(m2);
std::lock_guard lb2(m1); // blocks if thread A holds m1 — cycle!
// Fix: use scoped_lock for consistent simultaneous acquisition (C++17)
std::scoped_lock both(m1, m2); // deadlock-free regardless of orderSelf-deadlock on non-recursive mutex
// DEADLOCK: same thread, same non-recursive mutex
void outer() {
std::lock_guard lock(mtx);
inner(); // inner() tries to lock mtx again — undefined behavior
}
void inner() {
std::lock_guard lock(mtx); // second lock by same thread = deadlock
// ...
}
// Fix option 1: split into a locked public API and an unlocked internal helper
void outer() {
std::lock_guard lock(mtx);
inner_unlocked(); // assumes caller holds mtx
}
// Fix option 2 (last resort): std::recursive_mutex
std::recursive_mutex rmtx;Callback re-entry
// DEADLOCK: invoking a user-supplied callback while holding the lock
class EventBus {
std::mutex mtx_;
std::vector<std::function<void()>> handlers_;
public:
void fire() {
std::lock_guard lock(mtx_);
for (auto& h : handlers_) h(); // h() might call subscribe() — deadlock!
}
void subscribe(std::function<void()> h) {
std::lock_guard lock(mtx_);
handlers_.push_back(std::move(h));
}
};
// Fix: copy handlers under the lock, invoke outside it
void fire() {
std::vector<std::function<void()>> snapshot;
{
std::lock_guard lock(mtx_);
snapshot = handlers_;
}
for (auto& h : snapshot) h(); // lock not held — re-entry is safe
}The copy-then-invoke pattern is the standard remedy for callback re-entry deadlocks. It trades a minor extra allocation for the guarantee that the lock is not held during any user-supplied code.
See Also
std::atomic— lock-free arithmetic on scalars and pointers; prefer over mutex for simple counters and flagsstd::jthread— C++20 joinable thread with cooperative cancellation viastop_tokenstd::latch,std::barrier— C++20 one-shot and cyclic thread synchronization pointsstd::semaphore— C++20 counting semaphore for resource-count–bounded access