C++ Mutex & Lock — Mutual Exclusion and lock_guard

Key takeaways

A practical guide to C++ mutex and locks: mutex basics, lock_guard (RAII), unique_lock, multi-mutex locking, and real-world pitfalls.

Introduction

A mutex is a synchronization primitive for mutual exclusion. It stops multiple threads from accessing shared data at the same time and helps prevent data races.


Why is this needed even for something as small as ++sharedData? Because the increment is not one operation. The CPU loads the value, adds one, and stores it back; two threads can both load 0, both compute 1, and both store 1, losing an update. Beyond lost updates, the C++ memory model says any unsynchronized concurrent access where at least one side writes is a data race, which is undefined behavior: the compiler may keep the value in a register, reorder it with neighboring code, or tear a 64-bit write on some platforms. A mutex provides both mutual exclusion and ordering: everything a thread wrote before unlock() is visible to the next thread after its lock().

The hard part in practice is rarely the API. It is deciding which data a mutex protects and making every access go through it. Most mutex bugs I have seen in code review are not missing locks around the obvious write, but a read somewhere else — a getter, a logging statement, a size check — that touches the same data without the lock because “it’s only reading”.

This article is the overview of the building blocks: std::mutex itself, the RAII guards (lock_guard, unique_lock), the other mutex types, and how to choose between them. Locking several mutexes at once gets a short section here and its own article in C++ scoped_lock.

std::mutex basics

Basic usage

#include <mutex>
#include <thread>
#include <iostream>

// std::mutex: mutual exclusion — only one thread at a time
std::mutex mtx;
int sharedData = 0;  // shared data accessed by multiple threads

void increment() {
    // mtx.lock(): lock the mutex (other threads block until unlock)
    mtx.lock();
    
    // Critical section: at most one thread runs this at a time
    ++sharedData;
    
    // mtx.unlock(): release the mutex so another thread can enter
    mtx.unlock();
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    
    t1.join();
    t2.join();
    
    std::cout << "sharedData: " << sharedData << std::endl;  // 2
    
    return 0;
}

Problem: exception safety

#include <mutex>
#include <stdexcept>
#include <iostream>

std::mutex mtx;

void unsafeFunction() {
    mtx.lock();
    
    // If an exception is thrown, unlock never runs!
    if (someCondition) {
        throw std::runtime_error("error");
    }
    
    mtx.unlock();  // not reached if an exception is thrown
}

int main() {
    try {
        unsafeFunction();
    } catch (...) {
        std::cout << "Exception: mutex stays locked!" << std::endl;
    }
    
    return 0;
}

The core issue: If unsafeFunction throws before unlock, the mutex can remain locked forever. Threads that later wait on the same mutex can stall in a deadlock-like state.

Exceptions are only one path. An early return added months later during a bug fix skips unlock() just as surely, and so does a break out of a loop. The failure mode is also worse than a crash: the program keeps running with some threads blocked forever, and the symptom — requests that never finish — appears far from the function that forgot to unlock. Calling lock() twice from the same thread on a std::mutex is undefined behavior too; in practice it usually deadlocks on itself. These are the reasons manual lock()/unlock() pairs are considered a bug magnet, and why the next section’s RAII wrappers are the default.


lock_guard (RAII)

Automatic lock and unlock

#include <mutex>
#include <thread>
#include <iostream>

std::mutex mtx;
int sharedData = 0;

void safeIncrement() {
    std::lock_guard<std::mutex> lock(mtx);
    ++sharedData;
    // Destructor unlocks automatically
}

int main() {
    std::thread t1(safeIncrement);
    std::thread t2(safeIncrement);
    
    t1.join();
    t2.join();
    
    std::cout << "sharedData: " << sharedData << std::endl;  // 2
    
    return 0;
}

Pattern: lock_guard locks in the constructor and unlocks in the destructor, so release is guaranteed even on return or exception. Keeping the guard’s lifetime as small as possible shrinks the critical section and reduces contention.

A well-known typo turns this into a silent bug: std::lock_guard<std::mutex>(mtx); without a variable name. Depending on the form, that either declares a new variable named mtx (shadowing the global and locking nothing useful) or creates a temporary that is destroyed at the semicolon, so the lock is released before the critical section starts. It compiles, and the code looks locked in review. Always name the guard. Since C++17, class template argument deduction lets you write std::lock_guard lock(mtx); without the template argument.

To shorten a critical section, add a nested scope { std::lock_guard lock(mtx); ... } around just the shared-state access, and do slow work — I/O, logging, allocating, calling user callbacks — outside it. Calling unknown code (a callback, a virtual function) while holding a lock is a common source of deadlocks, because that code may try to take the same lock or another lock in the opposite order.

Exception safety

#include <mutex>
#include <stdexcept>
#include <iostream>

std::mutex mtx;

void safeFunction() {
    std::lock_guard<std::mutex> lock(mtx);
    
    // Even if this throws, unlock runs in the destructor
    if (someCondition) {
        throw std::runtime_error("error");
    }
    
    // Destructor of lock calls unlock
}

int main() {
    try {
        safeFunction();
    } catch (...) {
        std::cout << "Exception: mutex released automatically" << std::endl;
    }
    
    return 0;
}

unique_lock

Flexible locking

#include <mutex>
#include <iostream>

std::mutex mtx;

void flexibleLock() {
    std::unique_lock<std::mutex> lock(mtx);
    
    std::cout << "work 1" << std::endl;
    
    lock.unlock();
    
    std::cout << "work without lock" << std::endl;
    
    lock.lock();
    
    std::cout << "work 2" << std::endl;
    
    // Destructor unlocks if still held
}

int main() {
    flexibleLock();
    return 0;
}

Deferred locking

#include <mutex>
#include <iostream>

std::mutex mtx;

void deferredLock() {
    // Do not lock in the constructor
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock);
    
    if (needLock) {
        lock.lock();
    }
    
    // perform work
}

int main() {
    deferredLock();
    return 0;
}

unique_lock pays for its flexibility with an extra bool recording whether it currently owns the lock, which the destructor checks. That cost is negligible in almost every program; the real reason to prefer lock_guard is readability — a lock_guard can only mean “locked for the whole scope”. unique_lock is required for std::condition_variable::wait, which must unlock the mutex while sleeping and relock it before returning, and it is movable, so a function can acquire a lock and return it to the caller. Other constructor tags are std::try_to_lock (attempt without blocking; check owns_lock()) and std::adopt_lock (take over a mutex that is already locked), which appears in the next section.


Multiple mutexes

Deadlock risk

#include <mutex>
#include <thread>
#include <iostream>

std::mutex mtx1, mtx2;
int balance1 = 100, balance2 = 200;

// Bad: deadlock possible
void badTransfer() {
    // Thread 1: mtx1 then mtx2
    mtx1.lock();
    mtx2.lock();
    
    balance1 -= 50;
    balance2 += 50;
    
    mtx2.unlock();
    mtx1.unlock();
}

void anotherBadTransfer() {
    // Thread 2: mtx2 then mtx1 (deadlock!)
    mtx2.lock();
    mtx1.lock();
    
    balance2 -= 30;
    balance1 += 30;
    
    mtx1.unlock();
    mtx2.unlock();
}

If thread 1 acquires mtx1 and thread 2 acquires mtx2 at the same moment, each then waits forever for the mutex the other holds. This is the classic lock-ordering deadlock, and it is timing-dependent: the two functions can run correctly millions of times in tests and freeze once under production load. In real code the opposite order is rarely this visible. A typical form is a transfer(Account& from, Account& to) function that locks from then to — perfectly consistent code, until one thread transfers A→B while another transfers B→A. The lock order depends on the arguments, not on the code.

When a program is already stuck, attach a debugger (gdb -p <pid>, then thread apply all bt) and look for two threads blocked in pthread_mutex_lock / __lll_lock_wait, each holding what the other wants. ThreadSanitizer also reports potential lock-order inversions (lock-order-inversion (potential deadlock)) even in runs that did not deadlock.

Locking both without deadlock

The fix is to acquire both mutexes in one call that cannot deadlock, whatever order other threads use. Since C++17 that is std::scoped_lock:

void safeTransfer() {
    std::scoped_lock lock(mtx1, mtx2);  // order of arguments does not matter
    balance1 -= 50;
    balance2 += 50;
}   // both unlocked here, also on exceptions

Under the hood it calls std::lock, which locks one mutex, tries the others with try_lock, and backs off and retries if any is busy. In C++11/14 you write the same thing as std::lock(mtx1, mtx2) followed by two lock_guards constructed with std::adopt_lock. How the algorithm behaves under contention, the bank-transfer and swap examples, why scoped_lock cannot be moved, and when a fixed lock hierarchy is still needed are covered in C++ scoped_lock: Locking Several Mutexes Without Deadlock.


Mutex types

recursive_mutex

#include <mutex>
#include <iostream>

std::recursive_mutex rmtx;

void func1() {
    std::lock_guard<std::recursive_mutex> lock(rmtx);
    std::cout << "func1" << std::endl;
}

void func2() {
    std::lock_guard<std::recursive_mutex> lock(rmtx);
    std::cout << "func2" << std::endl;
    func1();  // OK: recursive lock on same mutex
}

int main() {
    func2();
    return 0;
}

With a plain std::mutex, func2 calling func1 would lock the same mutex twice on one thread and deadlock. recursive_mutex counts nested locks by the owning thread and releases only when the count returns to zero. It works, but it usually signals a design problem: if a public function can be entered while the class’s invariants are half-updated by an outer call on the same thread, the inner function may see inconsistent state that the mutex was supposed to prevent. The common refactoring is to split each operation into a public function that locks and a private *_locked helper that assumes the lock is held, and have public functions call only the helpers internally. recursive_mutex also cannot be used with std::condition_variable (only with condition_variable_any), and it is slightly slower.

timed_mutex

#include <chrono>
#include <mutex>
#include <thread>
#include <iostream>

std::timed_mutex tmtx;

void tryLockFor() {
    if (tmtx.try_lock_for(std::chrono::milliseconds(100))) {
        std::cout << "lock acquired" << std::endl;
        
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
        
        tmtx.unlock();
    } else {
        std::cout << "lock failed (timeout)" << std::endl;
    }
}

int main() {
    std::thread t1(tryLockFor);
    std::thread t2(tryLockFor);
    
    t1.join();
    t2.join();
    
    return 0;
}

In this example both threads usually succeed, because each holds the lock for 50 ms and the other waits up to 100 ms. Shorten the timeout below the hold time and the second thread reports a timeout. Timed locking is useful when a stuck lock should degrade gracefully (skip a cache refresh, return “busy”) rather than block a request forever. It is not a fix for deadlocks: retrying after a timeout without changing the lock order just produces a livelock that burns CPU. Pair try_lock_for with std::unique_lock (std::unique_lock lock(tmtx, std::chrono::milliseconds(100)); if (lock) {...}) to keep RAII unlocking instead of the manual unlock() shown here.


Practical example: threads

Thread-safe counter

#include <mutex>
#include <thread>
#include <vector>
#include <iostream>

class ThreadSafeCounter {
    mutable std::mutex mtx;
    int count = 0;
    
public:
    void increment() {
        std::lock_guard<std::mutex> lock(mtx);
        ++count;
    }
    
    void decrement() {
        std::lock_guard<std::mutex> lock(mtx);
        --count;
    }
    
    int get() const {
        std::lock_guard<std::mutex> lock(mtx);
        return count;
    }
    
    void reset() {
        std::lock_guard<std::mutex> lock(mtx);
        count = 0;
    }
};

int main() {
    ThreadSafeCounter counter;
    std::vector<std::thread> threads;
    
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back([&counter]() {
            for (int j = 0; j < 1000; ++j) {
                counter.increment();
            }
        });
    }
    
    for (auto& t : threads) {
        t.join();
    }
    
    std::cout << "final count: " << counter.get() << std::endl;  // 10000
    
    return 0;
}

The mutex is mutable so that get() can be const and still lock it: locking changes the mutex’s internal state, but not the counter’s logical value. Encapsulating the mutex with the data it protects is the important design choice here — callers cannot forget to lock because they never see the mutex.

The limit of this design shows up as soon as callers combine operations. if (counter.get() == 0) counter.increment(); is two separately locked calls; another thread can change the value between them, so the check is stale by the time the increment runs (a check-then-act race). Each method is thread-safe, but the sequence is not. The fix is to offer the combined operation as one method that holds the lock throughout (incrementIfZero()), rather than exposing the lock. For a single integer like this, std::atomic<int> is simpler and faster than a mutex; mutexes become necessary when an invariant spans several variables that must change together.


Choosing a lock type

LockCharacteristicsOverheadWhen to use
lock_guardSimple, RAIILowDefault choice
unique_lockFlexible, manual controlMediumConditional locking, std::condition_variable
scoped_lockMultiple mutexesLowMultiple mutexes (C++17)

Start with std::lock_guard, or std::scoped_lock, which behaves the same for one mutex and also handles several. Move to std::unique_lock only when you need what it adds: unlocking before the end of the scope, deferred or timed locking, or waiting on a std::condition_variable, which requires it.

Deadlocks come from two threads taking the same mutexes in different orders. Locking them together with std::scoped_lock(m1, m2) avoids that without a global ordering rule; when locks are taken at different points in the code, a documented order is the only protection. std::recursive_mutex usually hides a design problem, a public function calling another public function that locks the same mutex, and the common fix is a private unlocked helper that both call. For data read far more often than written, measure std::shared_mutex before switching, since its bookkeeping can cost more than it saves when critical sections are short.

Next steps



Frequently Asked Questions (FAQ)

Q. When should I use scoped_lock instead of lock_guard?

A. Use std::scoped_lock (C++17) when a critical section needs more than one mutex: it locks all of them with the same deadlock-avoidance algorithm as std::lock, so two threads locking the same pair in different orders cannot deadlock. With a single mutex it behaves like lock_guard, so many C++17 codebases use it everywhere. Use unique_lock instead when you need to unlock early, defer locking, or wait on a condition_variable.