Skip to content
C++
C++11 / C++17Intermediate

std::mutex, lock_guard, unique_lock

When multiple threads access shared data and at least one of them writes, the program has a data race — and undefined behavior. A mutex (mutual exclusion lock) is the fundamental tool for preventing this: only one thread can hold the mutex at a time, so protected code runs as a critical section. C++ provides std::mutex for the lock itself and RAII wrappers (lock_guard, unique_lock, scoped_lock) that ensure the mutex is always released — even when exceptions are thrown.

The problem: data races

A data race occurs when two threads access the same memory location, at least one access is a write, and there is no synchronization between them. Data races are undefined behavior in C++: the result is not merely unpredictable, it is completely invalid — the compiler can assume data races don't exist and generate code that is incorrect in surprising ways.

// DATA RACE — undefined behavior:
int counter = 0;

std::thread t1([] { for (int i = 0; i < 1000; ++i) ++counter; });
std::thread t2([] { for (int i = 0; i < 1000; ++i) ++counter; });

t1.join(); t2.join();
// counter might be anything — increments from t1 and t2 interleave.
// ++counter is not atomic: it compiles to read-modify-write, three steps.

// Why it's UB, not just "unpredictable":
// The compiler may cache counter in a register, reorder the operations,
// or generate completely different code based on the assumption no race exists.

std::mutex — mutual exclusion

std::mutex has three operations: lock() (blocks until the mutex is available then takes it), unlock() (releases it), and try_lock() (non-blocking; returns true if the lock was acquired). Call them directly only when necessary — RAII wrappers are almost always safer.

#include <mutex>

std::mutex mtx;
int counter = 0;

void safe_increment() {
    mtx.lock();
    ++counter;    // only one thread runs this at a time
    mtx.unlock();
    // BUG: if ++counter throws (it won't, but in general), unlock is never called
}

// Prefer RAII wrappers over raw lock/unlock.

std::lock_guard — simple RAII locking

std::lock_guard<std::mutex> locks the mutex on construction and unlocks it when the object is destroyed. It cannot be unlocked early — use it when the lock should be held for the entire scope.

std::mutex mtx;
int counter = 0;

void safe_increment() {
    std::lock_guard<std::mutex> guard(mtx);  // locks here
    ++counter;
    // unlocks here — destructor runs even if an exception is thrown
}

// C++17: deduction guides — no need to write the type:
std::lock_guard guard(mtx);   // deduces std::lock_guard<std::mutex>

// Common pattern with shared state in a class:
class SafeCounter {
public:
    void increment() {
        std::lock_guard guard(mtx_);
        ++value_;
    }
    int get() const {
        std::lock_guard guard(mtx_);
        return value_;
    }
private:
    mutable std::mutex mtx_;
    int value_ = 0;
};

The mutex member is declared mutable so it can be locked inside const member functions — the lock protects the physical state, not the logical state.

std::unique_lock — flexible locking

std::unique_lock is a more flexible RAII wrapper: it can unlock and re-lock during its lifetime, defer initial locking, and work with condition variables. Use it when you need these capabilities; use lock_guard otherwise (simpler is better).

std::mutex mtx;

// Deferred locking — don't lock yet:
std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
// ... do some work that doesn't need the lock ...
lock.lock();   // lock now
// ... critical section ...
lock.unlock(); // unlock early if needed
// ... more non-critical work ...
// destructor unlocks again if still locked

// Try-lock:
if (lock.try_lock()) {
    // acquired the lock
} else {
    // couldn't get it — do something else
}

// Condition variables require unique_lock:
#include <condition_variable>
std::condition_variable cv;
bool data_ready = false;

// Consumer thread:
std::unique_lock lock(mtx);
cv.wait(lock, [] { return data_ready; });  // atomically releases lock, sleeps, re-acquires
// process data...

// Producer thread:
{
    std::lock_guard guard(mtx);
    data_ready = true;
}
cv.notify_one();

std::scoped_lock (C++17) — lock multiple mutexes

Locking two mutexes in different orders in different threads causes deadlock — each thread holds one lock and waits for the other. std::scoped_lock (C++17) accepts multiple mutexes and uses a deadlock-avoidance algorithm to lock them all atomically.

std::mutex mtx_a, mtx_b;

// DEADLOCK risk: if another thread locks b then a:
// std::lock_guard ga(mtx_a);
// std::lock_guard gb(mtx_b);  // might wait forever

// SAFE: scoped_lock locks both atomically (deadlock-avoidance algorithm):
std::scoped_lock guard(mtx_a, mtx_b);   // C++17, locks both safely
// both mutexes are held here
// both released when guard is destroyed

Key rules to remember

Always use RAII lock guards — never raw lock()/unlock()

If an exception is thrown between lock() and unlock(), the mutex is never released and all other threads block forever. lock_guard prevents this.

Minimize the critical section — lock only what you need, as briefly as possible

A wide lock serializes all threads through that section, negating the benefit of concurrency. Move computation outside the lock; only protect the shared state update.

Use scoped_lock to acquire multiple mutexes — avoid ordering them manually

Manually locking mutexes in inconsistent order across threads causes deadlock. scoped_lock uses a deadlock-avoidance algorithm to prevent this.

Use unique_lock (not lock_guard) for condition variable waits

cv.wait() needs to temporarily release the mutex while sleeping. lock_guard can't do this; unique_lock can.

Sign in to track progress