C++ / CONCURRENCY AND PARALLELISM
Deadlocks and consistent lock ordering
Diagnose a two-mutex deadlock, fix it with a global lock order or std::scoped_lock, and enforce that order with a ranked mutex that throws.
What you will learn
- Read a hang as a cycle: each thread holds one mutex and waits for the other
- Lock two same-level mutexes in one std::scoped_lock instead of nesting guards
- Give every mutex a rank and acquire strictly downhill, program-wide
- Catch an order violation the moment it happens with a rank-checking mutex
Understanding Deadlocks and consistent lock ordering
A deadlock is neither a crash nor a slow path: it is two threads parked forever, burning no CPU. Thread 1 runs transfer(a, b) and locks a.m, thread 2 runs transfer(b, a) and locks b.m, and then each blocks on the mutex the other is holding. std::mutex::lock has no timeout and no way to give up, so both wait for a release that can never come. Four conditions have to hold at once (exclusive ownership, holding one lock while asking for another, no forcible take-back, and a cycle among the waiters), and the only one you can realistically design away is the cycle.
The mental model is a graph: one node per mutex, and an arrow from A to B every time any thread anywhere holds A while acquiring B. Deadlock is possible exactly when that graph contains a cycle, and the naive transfer draws both a.m to b.m and b.m to a.m, a cycle of length two. A consistent lock order is a rank on the nodes such that every arrow points strictly downhill, which makes a cycle impossible by construction. The order must be global: it is a property of the whole program, not something one function may decide from its own argument list.
Two mutexes at the same level, like the two accounts inside one transfer, have no natural rank, so you either invent one (compare their addresses with std::less) or let the library sidestep the question. std::scoped_lock over two or more mutexes locks them as if by std::lock, which try_locks and drops what it already holds when an attempt fails, so hold-and-wait never occurs. That covers only the mutexes named in that one constructor; a lock taken later, deeper in the call stack, or inside a callback invoked while holding a lock, is back under the ordering rules. Because the failure is timing dependent and may hide for months, prefer a rule you can verify by reading the code over a test run that happened to pass.
Deadlock avoidance is cheaper than deadlock recovery, so bake the ordering into the API where possible: a function that needs both accounts should take both and lock them once, rather than exposing lock() and unlock() to callers who will nest them in whatever order suits them.
<iostream>
<mutex>
<thread>
struct Account {
std::mutex m;
int balance;
explicit Account(int b) : balance(b) {}
};
// The version that deadlocks:
// std::lock_guard<std::mutex> g1(from.m);
// std::lock_guard<std::mutex> g2(to.m);
// It takes the mutexes in argument order, so transfer(a, b) on one thread and
// transfer(b, a) on another can each hold one and block on the other forever.
void transfer(Account& from, Account& to, int amount) {
if (&from == &to) return; // locking one mutex twice is UB
std::scoped_lock guard(from.m, to.m); // both, or neither, in one step
from.balance -= amount;
to.balance += amount;
}
int main() {
Account a{1000}, b{1000};
std::thread t1([&] { for (int i = 0; i < 100000; ++i) transfer(a, b, 1); });
std::thread t2([&] { for (int i = 0; i < 100000; ++i) transfer(b, a, 1); });
t1.join();
t2.join();
std::cout << "a = " << a.balance << '\n';
std::cout << "b = " << b.balance << '\n';
std::cout << "total = " << a.balance + b.balance << '\n';
}
A deadlock is a cycle in the graph of "holds one lock, wants another", so fix one global acquisition order for your mutexes and never let a call site derive its own.
Worked examples
Ordering by address when there is no natural rank
Two threads swap the same pair of nodes in opposite argument order, yet always acquire the mutexes in the same order because the order comes from the addresses.
<functional>
<iostream>
<mutex>
<thread>
<utility>
struct Node {
std::mutex m;
int value = 0;
};
void swap_values(Node& x, Node& y) {
Node* first = &x;
Node* second = &y;
if (std::less<Node*>{}(second, first)) std::swap(first, second);
std::lock_guard<std::mutex> g1(first->m);
std::lock_guard<std::mutex> g2(second->m);
std::swap(x.value, y.value);
}
int main() {
Node p, q;
p.value = 1;
q.value = 2;
std::thread t1([&] { for (int i = 0; i < 100000; ++i) swap_values(p, q); });
std::thread t2([&] { for (int i = 0; i < 100000; ++i) swap_values(q, p); });
t1.join();
t2.join();
std::cout << p.value << " " << q.value << '\n';
}
Example explained
Line 1std::less<Node*>{} is guaranteed to give a total order over pointers, while raw < between pointers to unrelated objects has an unspecified result.
Line 2After the conditional swap, first names the same node in both threads, so t1 and t2 acquire p.m and q.m in identical order and the wait-for graph has one arrow, not two.
Line 3std::swap(x.value, y.value) runs only after both guards exist, so no other swap_values call can observe a half-finished exchange.
Line 4200000 applications of the same transposition is an even number, so the values must come back to 1 and 2; a torn or lost swap would show up as 2 1.
A ranked mutex that throws instead of hanging
Wrapping std::mutex with a per-thread rank check turns a lock-order violation into an immediate exception on one thread, instead of a hang that needs two threads and bad timing.
<iostream>
<limits>
<mutex>
<stdexcept>
class RankedMutex {
public:
explicit RankedMutex(int rank) : rank_(rank) {}
void lock() {
if (rank_ >= held_rank())
throw std::logic_error("lock order violation");
m_.lock();
previous_ = held_rank();
held_rank() = rank_;
}
void unlock() {
held_rank() = previous_;
m_.unlock();
}
private:
static int& held_rank() {
static thread_local int rank = std::numeric_limits<int>::max();
return rank;
}
std::mutex m_;
int rank_;
int previous_ = 0;
};
int main() {
RankedMutex outer(100), inner(50);
{
std::lock_guard<RankedMutex> a(outer);
std::lock_guard<RankedMutex> b(inner);
std::cout << "outer then inner: ok\n";
}
try {
std::lock_guard<RankedMutex> a(inner);
std::lock_guard<RankedMutex> b(outer);
std::cout << "unreachable\n";
} catch (const std::logic_error& e) {
std::cout << "inner then outer: " << e.what() << '\n';
}
}
Example explained
Line 1held_rank() hides a function-local thread_local, because the current rank is a property of one thread's call stack and must not be shared between threads.
Line 2rank_ >= held_rank() also rejects equal ranks: two mutexes of the same rank define no order between themselves, which is exactly the transfer(a, b) case that needs scoped_lock instead.
Line 3previous_ is written just after m_.lock() and read just before m_.unlock(), so the mutex protects its own bookkeeping and nested locks unwind in LIFO order.
Line 4The throw happens inside the second lock_guard's constructor, so b never exists, while a still unlocks inner as the exception propagates.
Important notes
std::scoped_lock with a single mutex is just lock_guard and with none locks nothing; two or more arguments engage the deadlock-avoiding algorithm, which may loop through several try_lock rounds under heavy contention.
Switching to std::recursive_mutex only permits relocking by the same thread and does nothing for a two-thread cycle. Cycles can also close through a join() or a condition variable wait, which no lock-ordering rule covers.
Common mistakes
Letting the parameters decide the order: std::lock_guard g1(from.m) followed by std::lock_guard g2(to.m) looks orderly, but transfer(a, b) and transfer(b, a) produce opposite orders and the process stops dead, typically only under production load and never in the unit test.
Writing two separate scoped_locks, std::scoped_lock l1(a.m) then std::scoped_lock l2(b.m), and assuming the type is what saves you; that is ordinary nesting again, because the retry-and-back-off behaviour only exists when both mutexes are arguments to one constructor.
Forgetting the &from == &to check, then calling transfer(a, a, 50): scoped_lock locks the same non-recursive mutex twice, which is undefined behaviour and in practice wedges the thread against itself with no second thread involved.
Try it yourself
Change, predict, then run
Extend the transfer example with a third account c and a third thread running transfer(c, a, 1) in a loop, and confirm the total stays 3000. Then replace the single scoped_lock with two lock_guards in argument order and watch the program stop making progress.
Open the C++ workspaceCheck your understanding
Two threads call transfer(a, b, 10) and transfer(b, a, 10), and each body locks from.m then to.m with two separate lock_guards. Why does replacing both guards with one std::scoped_lock(from.m, to.m) remove the hang?
- scoped_lock acquires them with a try-and-release algorithm, so a thread that fails to get the second mutex drops the first instead of blocking while holding it
- scoped_lock sorts the mutexes by address before locking, so every call site ends up using the same order
- scoped_lock makes the mutexes recursive, so a thread may relock a mutex another thread already holds
- scoped_lock locks both mutexes in one atomic hardware instruction, so no other thread can interleave between them
Show answer
scoped_lock over several mutexes behaves as if by std::lock, which try_locks and releases everything it holds when an attempt fails, so hold-and-wait, and therefore the cycle, never forms regardless of the argument order. Sorting by address is a legitimate manual technique (see the swap_values example) but it is not what the standard requires of scoped_lock, and no atomic instruction locks two independent mutexes at once.