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::mutexC++11
std::recursive_mutexC++11
std::timed_mutexC++11
std::recursive_timed_mutexC++11
std::shared_timed_mutexC++14
std::shared_mutexC++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.

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