Mutexes, RAII Locks & Deadlock Prevention
A deadlock is two chefs where Chef A is holding the knife waiting for the cutting board, and Chef B is holding the cutting board waiting for the knife. Neither can move; both starve forever.
std::lock_guard is a minimal, non-movable RAII wrapper (8 bytes on 64-bit systems) that locks on construction and unlocks strictly on destruction. It has zero memory or performance overhead over raw locking.
std::unique_lock is a flexible, movable RAII wrapper (16 bytes) that maintains an internal boolean flag (owns_lock_). It supports deferred locking (std::defer_lock), timed locking (std::try_to_lock), explicit manual early unlock (lock.unlock()), and can be transferred into std::condition_variable::wait.
The 4 conditions are: (1) Mutual Exclusion, (2) Hold and Wait, (3) No Preemption, and (4) Circular Wait.
std::scoped_lock breaks the Circular Wait (and partially Hold and Wait) condition. When given multiple mutexes, it invokes std::lock, which implements a deadlock-avoidance algorithm (or acquires locks in a strict global address order). If acquiring a subsequent lock would block, it unlocks any already-held locks and backs off before retrying, preventing cyclic dependencies.
Modern OS mutexes use Futexes (Fast Userspace Mutexes). In the uncontended case, acquiring and releasing a mutex is purely a userspace atomic CAS operation that executes in nanoseconds without kernel intervention. If a thread attempts to acquire a contested mutex, the atomic CAS fails; the thread then invokes the futex system call, placing itself onto the OS kernel's wait queue where the OS deschedules it so it consumes zero CPU cycles until the owning thread releases the lock and executes a futex wake call.
Interviewer's lens
Interviewers use concurrency and synchronization questions to distinguish developers who merely know how to create threads from engineers who can design crash-proof, deadlock-free production systems.
What separates candidates:
- Junior instinct: Calls manual
mtx.lock()andmtx.unlock()(which leak permanently on early returns or thrown exceptions), or falls into the classic bidirectional transfer deadlock trap (transfer(A, B)vstransfer(B, A)). - Senior mastery: Understands the physical mechanics of deadlocks (the 4 Coffman conditions), chooses the exact RAII lock wrapper (
lock_guard,unique_lock,scoped_lock, orshared_lock) for the job, explains how the OS kernel uses fast userspace futexes to avoid burning CPU cycles, and prevents deadlocks withstd::scoped_lock.
Under the hood & memory layout
Think of a mutex as a single physical room key hanging next to a conference door:
- The Fast Path (Userspace CAS): When a thread tries to acquire the mutex, it executes an atomic Compare-And-Swap (CAS) on a 32-bit state flag in userspace. If uncontended, it takes the key in under 10 nanoseconds without bothering the OS kernel.
- The Slow Path (Kernel Sleep): If another thread already holds the key, the atomic CAS fails. The thread issues a
sys_futexsystem call. The OS moves the thread to a sleep wait queue, context-switching the CPU to other work so the thread consumes 0% CPU while blocked. - The Wakeup (Release & Handoff): When the owner calls
.unlock(), it resets the lock flag and prompts the kernel to wake the next sleeping thread. - The RAII Invariant: Wrappers like
std::lock_guardstore a pointer to the mutex and guarantee that.unlock()executes when leaving scope—even during stack unwinding after an exception.
Contains an atomic 32-bit lock state integer, an owner thread ID, a recursion count (for recursive mutexes), and a kernel wait-queue head pointer.
Zero-overhead non-movable RAII wrapper holding a single std::mutex*. Acquires lock in constructor; releases lock in destructor. Cannot be unlocked early.
Stores a std::mutex* (8 bytes) plus a boolean owns_lock_ flag (padded to 8 bytes). Movable, supports deferred locking, manual .unlock(), and condition variables.
Reader-Writer synchronization primitive. Maintains an atomic reader count and an exclusive writer bit to permit concurrent readers while serializing writers.
Core concepts & syntax traps
What is a Critical Section, Data Race & Why Raw mtx.lock() is Forbidden
What is a Critical Section, Data Race & Why Raw mtx.lock() is Forbidden
Three foundational rules govern shared memory safety:
- Critical Section: Any region of code reading or writing shared mutable data across threads. Only one thread must execute inside it at any moment.
- Data Race: Occurs when multiple threads access the same memory address concurrently, at least one access is a write, and no synchronization enforces ordering. In C++, this causes immediate Undefined Behavior (UB).
- The Raw
lock()Hazard: Callingmtx.lock()andmtx.unlock()manually is strictly banned in professional C++. If an early return, break, or thrown exception occurs between them, the mutex is permanently abandoned in a locked state—instantly deadlocking every future thread that tries to acquire it.
The Rule: Always wrap mutexes in an RAII lock guard so unlocking is physically guaranteed on scope exit.
std::mutex mtx;
// FORBIDDEN PATTERN:
void riskyWrite(int val) {
mtx.lock();
if (val < 0) return; // BUG: leaks lock permanently!
mtx.unlock();
}
// RAII PATTERN:
void safeWrite(int val) {
std::lock_guard<std::mutex> lock(mtx); // Unlocks automatically on any exit!
if (val < 0) return;
}Never call raw mtx.lock() / unlock(); always use an RAII lock wrapper to prevent lock leaks on early returns and exceptions.
The 4 C++ RAII Lock Wrappers Compared: lock_guard vs unique_lock vs scoped_lock vs shared_lock
The 4 C++ RAII Lock Wrappers Compared: lock_guard vs unique_lock vs scoped_lock vs shared_lock
C++ provides four specialized RAII lock wrappers. Choosing the right one balances flexibility against memory footprint:
| Lock Wrapper | Introduced | Movable? | Overhead | Key Capabilities |
|---|---|---|---|---|
std::lock_guard<M> | C++11 | No | 8B (pointer) | Minimal, strictly scoped, zero overhead. Cannot be unlocked early. |
std::unique_lock<M> | C++11 | Yes | 16B (ptr + flag) | Movable, defer_lock, manual .unlock(), and mandatory for std::condition_variable. |
std::scoped_lock<M...> | C++17 | No | Size of pointers | Locks 1 to N mutexes simultaneously using deadlock avoidance (std::lock). |
std::shared_lock<M> | C++14/17 | Yes | 16B (ptr + flag) | Acquires shared read lock on std::shared_mutex (multiple readers concurrent). |
Guideline: Default to std::scoped_lock for simple locks and multi-lock operations. Use std::unique_lock when you need condition variables or manual early unlocks.
std::mutex m1, m2;
std::shared_mutex rwMtx;
// 1. Scoped single lock
std::lock_guard<std::mutex> lg(m1);
// 2. Flexible movable lock with early release
std::unique_lock<std::mutex> ul(m1);
ul.unlock(); // release lock before expensive calculation
// 3. Multi-mutex deadlock-free lock (C++17)
std::scoped_lock sl(m1, m2);
// 4. Shared read lock (concurrent readers)
std::shared_lock rl(rwMtx);Use scoped_lock by default (especially for multiple mutexes), unique_lock for condition variables or early unlocks, and shared_lock for read-heavy caches.
The 4 Coffman Deadlock Conditions & The Classic Bank Transfer Trap
The 4 Coffman Deadlock Conditions & The Classic Bank Transfer Trap
Think of two cars meeting head-on on a narrow single-lane bridge: Car 1 refuses to back up until Car 2 moves, and Car 2 refuses to back up until Car 1 moves. Neither car can ever make progress.
Mechanically, a Deadlock occurs when threads freeze forever because each holds a lock that another thread is waiting for. A deadlock can only happen if all 4 Coffman conditions hold at the same time:
- Mutual Exclusion: Resources cannot be shared; only one thread can hold each lock.
- Hold and Wait: A thread holds lock A while waiting to acquire lock B.
- No Preemption: The OS cannot forcibly strip a lock away from a thread.
- Circular Wait: Thread 1 holds lock A and waits for B, while Thread 2 holds lock B and waits for A ($T_1 → L_2 → T_2 → L_1$).
The Classic Bank Transfer Trap
In transfer(Account& from, Account& to, int amount):
- Thread 1 runs
transfer(Alice, Bob): locks Alice, then attempts to lock Bob. - Thread 2 runs
transfer(Bob, Alice): locks Bob, then attempts to lock Alice. If both run simultaneously, Thread 1 waits for Bob forever while Thread 2 waits for Alice forever. The system freezes completely.
// DEADLOCK HAZARD:
void brokenTransfer(Account& from, Account& to, int amount) {
std::unique_lock<std::mutex> lockA(from.mtx); // T1 locks A
std::unique_lock<std::mutex> lockB(to.mtx); // T1 waits for B (while T2 holds B and waits for A!)
from.balance -= amount;
to.balance += amount;
}Deadlock requires all 4 Coffman conditions; breaking even one condition (such as Circular Wait) prevents deadlocks completely.
Deadlock Prevention: C++17 std::scoped_lock vs Global Lock-Address Ordering
Deadlock Prevention: C++17 std::scoped_lock vs Global Lock-Address Ordering
To prevent deadlocks across multiple mutexes, you only need to eliminate Circular Wait. C++ provides two mechanical solutions:
-
std::scoped_lock(C++17 Deadlock-Avoidance Algorithm):- Uses a try-and-back-off protocol (
std::lock). - It attempts to lock all mutexes. If it acquires the first lock but finds the second locked, it immediately releases the first lock (backs off), yields execution, and retries the entire sequence. This prevents circular holding.
- Uses a try-and-back-off protocol (
-
Strict Lock-Address Ordering (The Hardware / C++11 Alternative):
- Compare virtual memory addresses:
&from.mtx < &to.mtx. - Always lock the lower memory address first, then the higher address.
- Because all threads across the entire process acquire locks in identical numerical order, a cyclic dependency chain is mathematically impossible.
- Compare virtual memory addresses:
// C++17 Recommended approach:
void safeTransfer(Account& from, Account& to, int amount) {
if (&from == &to) return;
std::scoped_lock lock(from.mtx, to.mtx); // Deadlock-free!
from.balance -= amount;
to.balance += amount;
}
// Alternative: Address-based global hierarchy order
void manualHierarchyTransfer(Account& from, Account& to, int amount) {
if (&from == &to) return;
Account* first = &from < &to ? &from : &to;
Account* second = &from < &to ? &to : &from;
std::unique_lock<std::mutex> l1(first->mtx);
std::unique_lock<std::mutex> l2(second->mtx);
from.balance -= amount;
to.balance += amount;
}Always use C++17 std::scoped_lock when acquiring multiple mutexes to eliminate Circular Wait.
Reader-Writer Concurrency with std::shared_mutex and std::shared_lock
Reader-Writer Concurrency with std::shared_mutex and std::shared_lock
When 99% of your operations are reads (such as querying a DNS cache or reading config flags) and only 1% are writes, a standard std::mutex cripples performance by forcing readers to queue up one-by-one.
C++17 std::shared_mutex solves this by splitting access into two distinct modes:
- Shared Read Lock (
std::shared_lock):- Multiple reader threads can acquire shared access simultaneously.
- Dozens of threads can read the exact same cache entries in parallel with zero contention.
- Exclusive Write Lock (
std::unique_lock):- When an update occurs, the writer requests exclusive access.
- It waits for active readers to drain, then locks out all new readers and writers until the mutation completes.
Tradeoff: std::shared_mutex carries higher atomic bookkeeping overhead than std::mutex. Use it only when read operations dominate and hold the lock long enough to benefit from parallel reads.
std::shared_mutex rwMtx;
std::unordered_map<std::string, std::string> cache;
// Concurrent Read: Multiple readers execute simultaneously
std::string readCache(const std::string& key) {
std::shared_lock<std::shared_mutex> lock(rwMtx);
auto it = cache.find(key);
return (it != cache.end()) ? it->second : "";
}
// Exclusive Write: Blocks readers and writers
void writeCache(const std::string& key, const std::string& val) {
std::unique_lock<std::shared_mutex> lock(rwMtx);
cache[key] = val;
}Use std::shared_mutex with std::shared_lock for readers and std::unique_lock for writers in read-heavy concurrent caches.
Unlock Live Implementation & Interview Scenarios
The C++23 production code, live coding steps, scenario twist solutions, and common UB traps are part of Brute2Best Pro. Module 1 is completely free.
First anchor question per pattern, Part 1 LLD & Module 1 C++ basics are 100% Free forever.