72 / 163 · C++17 · 10 min
Mutual exclusion and RAII: mutex, scoped_lock, and deadlock
A mutex protects the invariant of a set of shared state, not a variable name. Bind the lock's lifetime with RAII. When several objects must be updated together, acquire every required lock in one step. Avoid lock-order cycles, and avoid waiting for a thread or calling unknown code while a lock is held.
In this lesson
A lock protects a whole operation, not a single read or write
When several threads access the same ordinary mutable state, name the lock that owns it and make every conflicting access follow that protocol. Locking only the writes while the reads run unsynchronized can still be a data race. An unlock synchronized-with a later lock of the same mutex, so a later critical section can observe earlier modifications.
Take a transfer between accounts. Debit and credit together keep the total balance unchanged. Locking each assignment on its own is not enough: another thread can observe a debit without the matching credit. Check the condition and modify both ends inside one critical section. The invariant of the shared state decides the lock's scope. Do not place a lock mechanically on every line.
Choose the RAII lock that matches the needed capability
lock_guard locks in its constructor and unlocks in its destructor. It fits a fixed critical section. unique_lock can be moved, can delay locking, and can unlock temporarily. Waiting on a condition variable needs that transferable lock state. Both release an acquired lock on early return and on exception unwind, so handwritten lock/unlock pairs are not left unmatched.
C++17 scoped_lock can manage several locks at once and acquires them with a deadlock-avoidance algorithm. The example hands it both account mutexes in one step. Another thread that transfers with the arguments reversed does not form this two-lock deadlock. The lock object must have a name. A temporary is destroyed at the end of the statement, and later access is already unprotected.
Avoiding one deadlock locally does not make the whole program deadlock-free
A common deadlock is that one thread holds A and waits for B while another holds B and waits for A. A single lock hierarchy, or acquiring the complete set of needed locks together, removes that cycle. Do not pass the same non-recursive mutex to scoped_lock twice. The example recognizes a transfer to self and returns, so the same object is not locked twice.
scoped_lock does not analyze every external dependency. If you hold a lock and then call a callback, wait for a future, or join, and the waited party needs that lock, you can still deadlock. Move expensive work out of the critical section. Keep only the check and the commit of state.
The example transfers one unit one hundred times on each side. The initial balances cover any interleaving, and the final result does not depend on scheduling. The main thread reads the balances after join, when there are no concurrent writes.
Pitfalls
- Using two different mutexes for reads and writes of the same balance does not establish mutual exclusion; the protocol must use the same lock.
recursive_mutexonly lets the same thread lock again. It does not remove wait cycles across threads, and it does not replace a clear interface boundary.
Run an example
Minimum C++17 · complete program · Download .cpp
#include <cassert>
#include <iostream>
#include <mutex>
#include <thread>
struct Account {
std::mutex mutex;
int balance = 1000;
};
void transfer_one(Account& from, Account& to) {
if (&from == &to) return;
std::scoped_lock lock(from.mutex, to.mutex);
assert(from.balance > 0);
--from.balance;
++to.balance;
}
int main() {
Account a;
Account b;
std::thread worker([&] {
for (int i = 0; i < 100; ++i) transfer_one(a, b);
});
for (int i = 0; i < 100; ++i) transfer_one(b, a);
worker.join();
assert(a.balance == 1000 && b.balance == 1000);
transfer_one(a, a);
assert(a.balance + b.balance == 2000);
std::cout << a.balance << ' ' << b.balance << '\n';
}
Compile locally
g++ -std=c++17 -Wall -Wextra -Wpedantic -pthread concurrency-mutex.cpp -o example && ./exampleExpected result
1000 1000
CHECK YOUR UNDERSTANDING
Close the answer. Explain it.
If a function is added that reads the combined balance of two accounts, may it lock and read each account separately and then add? Give a consistent-snapshot version.
Show a reference answer
That does not guarantee a consistent snapshot: a transfer may complete between the two reads, so the balances come from different states. For distinct accounts, use std::scoped_lock lock(a.mutex, b.mutex) and return a.balance + b.balance while both locks are held. If the interface allows both arguments to name the same account, handle aliasing first; do not pass the same mutex twice.
Check the sources
- cppreference: std::scoped_lock multi-lock management and notes
- C++ working draft: [intro.races] synchronization and mutexes
Drafts and official chapters change. The version mark is only the example’s minimum.