1. What deadlock is and why the program seems to “hang”
When you catch a deadlock for the first time, the feeling is usually: “That's it, my code went to ponder life.” And the worst part — it can ponder forever, without crashing with an error or printing anything useful. Deadlock is like a queue at a clinic where two doctors went to have tea because each waits for the other to return a patient file.
Deadlock (mutual blocking) is a state where two or more threads wait for each other forever, because each holds a resource (typically a mutex) and tries to acquire another resource held by another thread. Unlike “long computations”, a deadlock never ends (unless you terminate the process).
Typical symptoms in practice look like this: the program consistently “hangs” at the same place, CPU load may be low, console output stops, and join() on threads in main never returns. And here's the sneakiness: sometimes it happens once in a thousand runs because it depends on the OS scheduler and timings.
For the rest of the discussion remember this thought: deadlock is almost always a lock acquisition protocol bug, not “bad luck”.
2. Classic deadlock: two mutexes and different acquisition orders
Let's start with a minimal example to see the problem with our own eyes. It's intentionally slightly “tweaked” with sleep_for so the chance to catch a hang is higher. In real life, instead of sleep_for you typically have any unexpected slowdown: logging, I/O, disk access, memory allocation, or even a context switch at the wrong moment.
#include <chrono>
#include <mutex>
#include <thread>
std::mutex mA;
std::mutex mB;
void f1() {
std::lock_guard<std::mutex> lockA(mA);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lockB(mB);
}
void f2() {
std::lock_guard<std::mutex> lockB(mB);
std::this_thread::sleep_for(std::chrono::milliseconds(10));
std::lock_guard<std::mutex> lockA(mA);
}
If you run this from main in two threads, sometimes you'll see the situation: thread t1 managed to lock mA and waits for mB, while thread t2 locked mB and waits for mA. Neither can proceed. We stand politely holding the doors for each other.
#include <thread>
int main() {
std::thread t1(f1);
std::thread t2(f2);
t1.join();
t2.join(); // sometimes hangs forever
}
It's important to understand the actual reason: not “mutexes are bad”, but that we acquired them in different orders. In one place “first A then B”, in another “first B then A”.
3. Deadlock as a protocol bug
Now an important point: deadlock is usually not fixed by “adding another mutex” (that would be like putting out a fire with gasoline — it may look dramatic). It's fixed by introducing a clear protocol for how locks are acquired in the project and making that protocol hard to violate.
If you describe deadlock in plain terms, it occurs when four conditions hold simultaneously: resources are exclusive (a mutex is owned by one thread), a thread holds one resource and requests another, you cannot forcibly take a mutex away from a thread, and a cycle of waiting appears. In our examples the cycle is elementary: t1 waits for mB, t2 waits for mA. The ring is closed.
Why do I emphasize this: it explains why the most effective prevention rule is not “pray”, but to break the possibility of a wait cycle. Practically this is done either by a single acquisition order (lock ordering) or by acquiring multiple mutexes in one operation.
By the way, even the standard library documentation contains notes and adjustments related to deadlock avoidance — the problem is so common it's highlighted at the specification level.
4. Rule #1: a single lock acquisition order
Now we introduce a rule that has saved more projects than “let's rewrite everything to a single thread”. It's embarrassingly simple, but it works almost always: if you need to take multiple mutexes, take them in the same order throughout the project.
The order may be arbitrary; the point is being consistent and documented. For example:
- “First the database mutex, then the logger mutex.”
- “First the smaller id, then the larger id.”
- “First Bank::m_, then Logger::m_.”
The deadlock diagram — what we don't want:
flowchart LR
T1["Thread 1 holds mA"] -->|waits| mB["mB"]
T2["Thread 2 holds mB"] -->|waits| mA["mA"]
The “single order” diagram — what we want:
flowchart TD
R["Rule: always acquire mA → then mB"] --> OK["No wait cycle forms"]
I'll show it on the same minimal example: if both f1 and f2 acquire mA first and then mB, the order-based deadlock disappears.
void f2_fixed() {
std::lock_guard<std::mutex> lockA(mA);
std::lock_guard<std::mutex> lockB(mB);
}
Note: we didn't make the code “faster”. We made it impossible to break in this particular way.
5. Rule #2: acquire multiple mutexes in one operation
A very common situation in real code: an operation is logically atomic, but data lives under different mutexes. It's easy to make a mistake and acquire them in a different order somewhere. Therefore a very useful habit is to acquire multiple mutexes with one construct, for example via std::scoped_lock.
The idea of std::scoped_lock is: “I want to take multiple mutexes at once and hold them for the rest of the scope”. You write a single expression, making the intention obvious: “I need both resources simultaneously”.
#include <mutex>
extern std::mutex mA;
extern std::mutex mB;
void safe() {
std::scoped_lock lock(mA, mB);
// work with both resources
}
Why is this better than two lock_guard in a row? Because “two lock_guard” look innocent, but they can be swapped in another part of the code. And scoped_lock(mA, mB) visually binds “this pair” and reduces the chance that someone (including you a week later) will do mB then mA.
Here's an engineering idea: we don't just write “correct code”, we write code that's harder to break by an accidental edit.
6. Example: a mini-bank and transfers between accounts
Now let's build a small educational scenario. Imagine we write a console app “MiniBank”: there are accounts, balances, transfers. Everything is simple, but we want to perform transfers from different threads (for example, simulating client operations).
Start with the account model:
#include <mutex>
struct Account {
int id = 0;
int balance = 0;
mutable std::mutex m;
};
A naive transfer looks logical: lock from, then lock to, perform the operation.
void transfer_bad(Account& from, Account& to, int amount) {
std::lock_guard<std::mutex> lockFrom(from.m);
std::lock_guard<std::mutex> lockTo(to.m);
from.balance -= amount;
to.balance += amount;
}
And here's the trap: if in another thread a transfer runs in the opposite direction (to → from), the acquisition order will be mirrored. Sometimes they “meet” and hang.
To simulate threads “converging”, add a small pause. (In reality anything can stand in for it, like logging.)
#include <chrono>
#include <thread>
void transfer_bad(Account& from, Account& to, int amount) {
std::lock_guard<std::mutex> lockFrom(from.m);
std::this_thread::sleep_for(std::chrono::milliseconds(1));
std::lock_guard<std::mutex> lockTo(to.m);
from.balance -= amount;
to.balance += amount;
}
Usage:
#include <thread>
int main() {
Account a{.id = 1, .balance = 1000};
Account b{.id = 2, .balance = 1000};
std::thread t1([&]{ transfer_bad(a, b, 10); });
std::thread t2([&]{ transfer_bad(b, a, 20); });
t1.join();
t2.join(); // sometimes hangs
}
Fix: std::scoped_lock for both mutexes
The most direct fix is to take both mutexes with one construct:
void transfer_scoped(Account& from, Account& to, int amount) {
std::scoped_lock lock(from.m, to.m);
from.balance -= amount;
to.balance += amount;
}
Fix: ordering by id
Sometimes you want to enforce a rule explicitly: “always lock the smaller id first”. Then the code will be identical regardless of transfer direction.
Below is an example of a bad idea (logically bad, though syntactically correct): comparing the addresses of fields rather than the id values. It's easy to write “by accident”, it will compile, but such logic doesn't express the required order and can behave unpredictably.
void transfer_ordered_bad(Account& x, Account& y, int amount) {
Account* first = (&x.id < &y.id) ? &x : &y; // don't do this
Account* second = (first == &x) ? &y : &x;
std::scoped_lock lock(first->m, second->m);
x.balance -= amount;
y.balance += amount;
}
It's better to compare the id values themselves, not addresses of “something inside”. Let's do it correctly and briefly:
void transfer_ordered(Account& from, Account& to, int amount) {
Account& first = (from.id < to.id) ? from : to;
Account& second = (from.id < to.id) ? to : from;
std::scoped_lock lock(first.m, second.m);
from.balance -= amount;
to.balance += amount;
}
Note the nice effect: we don't have to use scoped_lock, we could have used two lock_guard in sequence. But scoped_lock here adds an extra safety layer: even if the two mutexes appear “in a different order” in the future, the intention “take both” is still expressed by a single object.
7. Deadlock due to side effects under a lock
Many deadlocks are born not where you explicitly wrote “I'll acquire two mutexes”. They are born where you think: “I'll quickly log this.” Now you introduced a second mutex — the logger mutex — and have unknowingly started building a chain of locks.
Let's make a simplest thread-safe logger:
#include <iostream>
#include <mutex>
#include <string>
class Logger {
public:
void log(const std::string& s) {
std::lock_guard<std::mutex> lock(m_);
std::cout << s << '\n';
}
private:
std::mutex m_;
};
Imagine that Bank has its own mutex for data, and inside a “bank” operation we call the logger.
#include <mutex>
class Bank {
public:
Bank(Logger& logger) : logger_(logger) {}
void deposit(Account& a, int amount) {
std::lock_guard<std::mutex> lock(m_);
a.balance += amount;
logger_.log("deposit"); // logging under lock
}
private:
std::mutex m_;
Logger& logger_;
};
At first glance everything seems fine. But trouble comes when somewhere else someone does the opposite: logs first, then goes into the bank. For example, “print_report”:
void print_report(Bank& bank, Logger& logger) {
logger.log("report"); // logger is locked inside log()
// ... and then something that leads to acquiring bank.m_ (for example, bank.total())
}
If inside “print_report” you call a bank method that locks Bank::m_, you'll get a potential cycle: one thread holds Bank::m_ and waits for Logger::m_, another holds Logger::m_ and waits for Bank::m_.
How to fix this properly? There are two variants, both about discipline.
First — a single order: for example, “always Bank::m_ first, then Logger::m_”. Then print_report should be arranged so that it doesn't log before acquiring the bank state (or it logs without the mutex — but that's another discussion).
Second — reduce time under lock and “snapshot the state”: under the bank mutex you prepare a string or data for the report, release the mutex, and only then print/log.
Here's a “snapshot” example (idea matters more than content):
#include <string>
std::string make_report_line(const Account& a) {
return "id=" + std::to_string(a.id) + " balance=" + std::to_string(a.balance);
}
And now a careful output: a short critical section only to read data, then output without holding the bank mutex.
void print_account(Logger& logger, const Account& a) {
std::string line;
{
std::lock_guard<std::mutex> lock(a.m);
line = make_report_line(a); // read under lock
}
logger.log(line); // print without account lock
}
The idea: we don't hold two mutexes “just in case”. The fewer intersections of locks, the lower the chance to form a wait cycle.
8. Practical rules to prevent deadlock
Now it's useful to collect a “set of habits” in your head. Not magic spells, but engineering solutions for different cases. The table below is not about the “one true way”, but about which tool best expresses the intention.
| Situation | Best rule | Why |
|---|---|---|
| You need to take multiple mutexes for one operation | |
One expression, less chance to make a mistake and easier to read |
| There is a “natural order” of objects (id, index, name) | lock ordering by that key | Easy to explain and check: “smaller id first” |
| Heavy work under lock (I/O, computation, long loops) | “snapshot under lock → work without lock” | Reduces hold time, lowers intersections of locks |
| Under lock you call “foreign” functions (logger, callbacks, user code) | don't call them under lock or strictly fix the order | “Foreign function” may take another mutex and break the protocol |
9. How to tell deadlock from “just slow”
When a program “hangs”, a beginner often suspects everything: from “cout is broken” to “the computer is tired”. It's important to learn to distinguish deadlock from ordinary delays.
Deadlock usually manifests like this: the process is alive, but nothing happens; if you have debug prints “at the start of function” and “at the end of function”, you see the start printed but the end never appears. If you're familiar with a debugger, a typical picture is — one thread is stuck trying to acquire a mutex, the other thread is stuck trying to acquire another mutex. That is, each waits to enter a critical section, but the entry never happens.
A useful practical habit: if you write code with multiple mutexes, assume deadlock is possible “by default” until you prove otherwise with a protocol. It's not paranoia — it's respect for reality.
10. Typical mistakes when protecting against deadlock
Mistake #1: “I'll take two mutexes, order doesn't matter”.
In practice order always matters as soon as there is more than one place in the project where these mutexes can cross. Today you take A → B, tomorrow a colleague (or you in a week) will take B → A, and you'll get a hang that's impossible to “guess” by logic because it depends on timings.
Mistake #2: “I'll put mutexes everywhere to be absolutely safe”.
If you start holding two or three mutexes across half a function “for safety”, you yourself increase the number of intersections and the chance of wait cycles. In concurrency, calm usually comes not from the number of locks, but from short critical sections and a clear protocol.
Mistake #3: “Under a lock you can call any function, it's just a call”.
Calling a “foreign” function under a lock is one of the most dangerous sources of deadlock. “Foreign” means not only a library, but also your own logger, your own print_report, your own “small helper”. Inside it can take another mutex — and you've already built a cycle without noticing.
Mistake #4: “Logging under a mutex is safe because it's useful”.
Logging is useful, but it almost always uses a shared resource (console, file, buffer) and therefore is synchronized too. If the logger has its own mutex, you automatically introduce a second resource that can intersect with others. A good habit is to prepare the message under lock and write it without the lock (or strictly fix the order “data first, then log”).
Mistake #5: “Deadlock happened — I'll add another mutex for coordination”.
An additional mutex rarely fixes the protocol; it more often adds another vertex to the wait graph. The fix usually starts with listing which mutexes exist, which pairs are taken together, and in what order. After that it becomes clear where the order is violated — and the fix is often surprisingly small.
GO TO FULL VERSION