A deadlock is when two or more threads each wait for something another one holds, so none of them can ever proceed. Unlike a crash, nothing fails loudly: the threads are alive, CPU usage drops to zero, and the system just hangs. Understanding the four conditions behind deadlock tells you exactly how to design it out.
The simplest example#
Two threads each need two locks, and they take them in opposite order:
| Step | Thread 1 | Thread 2 |
|---|---|---|
| 1 | lock(A): acquired |
lock(B): acquired |
| 2 | lock(B): blocked |
lock(A): blocked |
Thread 1 holds A and needs B; Thread 2 holds B and needs A. Neither can release what it holds, because it is stuck waiting, and neither can get what it needs.
Everyday versions: two people at opposite ends of a narrow hallway, each saying "I'll move when you move." Or you hold a file open and need the printer, while your roommate has reserved the printer and needs the same file.
Why deadlocks are nasty#
Compare how three kinds of concurrency bug show themselves:
| Bug | What the system does | What you get |
|---|---|---|
| Race condition | Runs | A wrong answer |
| Crash | Dies | An error message |
| Deadlock | Hangs | Nothing at all (hardest to debug) |
No exception, no log line. Users just see a frozen app.
Where they show up:
| Where | How it happens |
|---|---|
| Databases | Txn 1 locks row A and wants row B; Txn 2 locks row B and wants row A |
| Microservices | Service A holds X and calls B for Y; B holds Y and calls A for X |
| File systems | Process 1 opens file A then B; Process 2 opens B then A |
The four conditions (Coffman conditions)#
All four must hold at the same time. Remove any one and deadlock is impossible.
| # | Condition | Meaning |
|---|---|---|
| 1 | Mutual exclusion | A resource can be held by only one thread at a time. |
| 2 | Hold and wait | A thread holds at least one resource while waiting for another. |
| 3 | No preemption | Nobody can forcibly take a resource; the holder must release it. |
| 4 | Circular wait | The waits form a cycle: T1 → T2 → T1, or T1 → T2 → T3 → T1. |
Mapped onto the example:
| Condition | In the example |
|---|---|
| Mutual exclusion | lock(A) and lock(B) are each held by one thread |
| Hold and wait | T1 holds A and wants B; T2 holds B and wants A |
| No preemption | Nothing can take A from T1 or B from T2 |
| Circular wait | T1 waits on T2, and T2 waits on T1 |
All four hold, so the threads deadlock.
Without a cycle, say T1 waits for T2 but T2 waits for nothing, T2 eventually finishes and releases, and T1 continues.
Prevention: break one condition#
Since all four conditions are required, you prevent deadlock by designing out any one of them.
| Break | Strategy | Practical? | Downside |
|---|---|---|---|
| Circular wait | Global lock ordering | Most practical | Every code path must follow the order |
| Hold and wait | Acquire all locks at once, or none | Moderate | Must know locks upfront; wasted work; livelock risk |
| No preemption | tryLock with timeout, release and retry |
Moderate | Partial work needs rollback |
| Mutual exclusion | Make resources sharable (read-write locks) | Limited | Only helps read-heavy workloads |
Break circular wait: lock ordering#
Give every lock a global order and always acquire in that order (for example, by ID ascending).
With the rule "always acquire A before B", the same two threads now take turns:
| Step | Thread 1 | Thread 2 |
|---|---|---|
| 1 | lock(A): acquired |
|
| 2 | lock(A): blocked (Thread 1 has it) |
|
| 3 | lock(B): acquired, does work, unlock(B), unlock(A) |
|
| 4 | lock(A) acquired, lock(B) acquired, does work, unlocks |
There is no cycle, so there is no deadlock.
The original deadlock happened because Thread 1 went A → B and Thread 2 went B → A. A single order removes the cycle. This is the most common real-world fix: simple and easy to check in code review.
import threading
from dataclasses import dataclass, field
@dataclass
class Account:
id: int
balance: int
lock: threading.Lock = field(default_factory=threading.Lock)
def transfer(src: Account, dst: Account, amount: int) -> None:
# Transfer between two accounts without deadlock:
# always lock the account with the smaller ID first.
first, second = sorted((src, dst), key=lambda a: a.id)
with first.lock, second.lock:
src.balance -= amount
dst.balance += amount
Break hold and wait: all or nothing#
Acquire every lock you need in one step, or none.
| Code | Effect | |
|---|---|---|
| Bad | lock(A); lock(B) |
Holds A while waiting for B |
| Fix | lockBoth(A, B) |
Gets both or neither |
If you can't get them all, release everything and retry:
lock(A).tryLock(B)fails.unlock(A).- Wait a random time.
- Retry.
Downsides: you may not know every lock upfront, releasing throws away work, and if threads keep retrying in lockstep you get livelock: everyone is busy, nobody progresses. The fix is random backoff. See Starvation vs Deadlock vs Livelock.
Break no preemption: force a release#
If a thread can't get what it needs, it gives up what it holds. In Python, this is acquire() with a timeout:
import threading
a = threading.Lock()
b = threading.Lock()
with a:
if b.acquire(timeout=0.1):
try:
do_work()
finally:
b.release()
else:
# Couldn't get B in 100 ms: fall through, release A, retry later
pass
Downside: the thread may have half-modified shared data when it gives up. Releasing then exposes an inconsistent state, so you need to roll back partial work first.
Databases work this way: they detect the deadlock, pick a victim transaction, roll it back, and let the other proceed. The application (or driver) retries the aborted transaction.
Break mutual exclusion: share where possible#
Make resources sharable so several threads can hold them. Read-write locks are the practical form: many readers or one writer.
| Thread | Call | Result |
|---|---|---|
| Reader 1 | readLock(A) |
OK |
| Reader 2 | readLock(A) |
OK: concurrent reads are safe |
| Writer 1 | writeLock(A) |
Blocked until all readers finish |
This helps a lot when 90% of operations are reads and barely at all when most are writes, so mutual exclusion is usually impossible to remove completely.
Key takeaways#
- Deadlock needs all four: mutual exclusion, hold and wait, no preemption, circular wait.
- Global lock ordering (break circular wait) is the simplest and most common prevention.
- All-or-nothing acquisition and
tryLock-with-timeout work too, but bring livelock and rollback concerns; add random backoff. - Databases detect deadlocks and abort one transaction, so your code should be ready to retry.
- A deadlocked system hangs silently; thread dumps showing threads blocked on each other's locks are the giveaway.