bigonotes
CS ConceptsOS Fundamentals

Deadlocks

The four conditions that cause a deadlock, and how breaking each one (especially lock ordering) prevents it.

5 min readLast updated OS FundamentalsListen · Watch on NotebookLM

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.

Thread 1 waits for Lock B, which is held by Thread 2, which waits for Lock A, which is held by Thread 1, forming a cycle

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:

  1. lock(A).
  2. tryLock(B) fails.
  3. unlock(A).
  4. Wait a random time.
  5. 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.

Found a mistake? Tell me on LinkedIn ↗

Further reading#