Skip to content
C++
Library
since C++11
Intermediate

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++11

A 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.

cpp
#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

TypeRecursive?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.

cpp
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.

cpp
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.

cpp
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.

cpp
#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.

cpp
#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:

cpp
// 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.

cpp
#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:

cpp
Logger& logger() {
    static Logger instance;  // C++11: initialization is thread-safe
    return instance;
}

Common Pitfalls

Unnamed temporary — destroyed immediately

cpp
// 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

cpp
cv.wait(lock);  // WRONG: spurious wakeup exits even if condition is false

cv.wait(lock, [&] { return !queue_.empty() || done_; });  // CORRECT

Protecting only half a transfer

cpp
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.

cpp
// 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

  1. Consistent acquisition order — always lock m1 before m2 across all code paths.
  2. std::scoped_lock (C++17) — acquire multiple locks in one statement; deadlock impossible.
  3. Lock hierarchies — assign numeric levels; a thread holding level N may only acquire level < N next.
  4. Avoid nested locks — one mutex at a time unless scoped_lock covers 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.

cpp
// 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.

cpp
// 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/unlock

std::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.

cpp
// 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.

cpp
// 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.

cpp
// 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);
}
cpp
// 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++20

Reach 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).

cpp
// 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

cpp
// 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 order

Self-deadlock on non-recursive mutex

cpp
// 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

cpp
// 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 flags
  • std::jthread — C++20 joinable thread with cooperative cancellation via stop_token
  • std::latch, std::barrier — C++20 one-shot and cyclic thread synchronization points
  • std::semaphore — C++20 counting semaphore for resource-count–bounded access