Deadlock, Livelock and Starvation
Locks fix the lost update and introduce a new failure: a system in which every thread is waiting and nothing will ever happen. Deadlock needs four conditions at once, and breaking any one of them prevents it. The usual choice is a single global order in which every lock is taken.
Deadlock has two cousins. In livelock everyone is busy and nobody makes progress. In starvation the system makes progress and one thread never does. The three look different from outside, need different signals to catch and share a root: threads waiting on each other in a pattern nobody drew.
The Deadly Embrace
Thread A holds lock 1 and waits for lock 2. Thread B holds lock 2 and waits for lock 1. Neither can proceed, neither will let go, and nothing else will intervene. The processor is idle and nothing logs an error.
The classic example is a money transfer that locks the source account and then the destination, so that the two balances change together. It works for months. It deadlocks the first time two transfers between the same two accounts run in opposite directions at the same moment: one locks account 17 and waits for account 42, the other locks account 42 and waits for account 17.
The Four Conditions
Coffman, Elphick and Shoshani set out the conditions in 1971, and deadlock needs all four at once. Mutual exclusion: a resource is held by one thread at a time. Hold and wait: a thread holding one resource may request another. No preemption: nothing can take a lock away from the thread that holds it. Circular wait: there is a cycle in who waits for whom.
Because all four are required, every prevention strategy removes one of them. Sharing a resource removes the first, taking every lock at once removes the second, allowing a lock to be taken away removes the third, and a lock order removes the fourth. In practice the fourth is the one engineers can remove cheaply.
Lock Ordering
Give every lock a rank and always acquire locks in increasing rank. A cycle then cannot form, because closing it would require some thread to acquire a lower-ranked lock while holding a higher one, which the rule forbids. The rank is a topological order over the locks, the idea Chapter 8 develops for dependency graphs.
The transfer is fixed in one line: always lock the account with the lower id first, whichever direction the money moves. Both transfers between accounts 17 and 42 now try account 17's lock first, one of them waits there holding nothing, and the cycle never closes.
Detection and the Timeout
A deadlock is a cycle in the wait-for graph, the graph with an edge from each waiting thread to the thread it waits for, and finding a cycle is the graph search of Chapter 8. Databases build this graph, find cycles and abort one of the transactions in the cycle, which the application must then retry.
Code without a detector puts a timeout on acquiring a lock. A timeout breaks hold-and-wait by giving up. It is honest, because it admits that a deadlock can happen and turns it into an error. It is also dangerous: if the retry repeats the same order at once, the same collision happens again.
Livelock and Starvation
Two threads that detect a conflict, both back off and both retry at the same instant can repeat that forever, busy the whole time and getting nowhere, like two people stepping aside in a corridor in step with each other. Randomized backoff breaks the symmetry, the way the random pivot of Chapter 9 keeps any one input from being bad every time. Backend Deep Dive covers the practice in its topic Retries, Backoff and Jitter.
Starvation is a thread that never gets the lock because others always win it: an unfair lock under load, or a writer waiting behind an endless stream of readers. Its sharpest form is priority inversion. A low-priority task holds a lock that a high-priority task needs, while medium-priority work keeps the low-priority holder off the processor, so the high-priority task waits on the least important work in the system. This reset the Mars Pathfinder lander repeatedly in 1997, until engineers switched on priority inheritance, which lends the holder the waiter's priority, with a patch uploaded from Earth.
How Each Failure Looks in Production
A deadlocked service shows near-zero processor use and a full thread pool, which a dashboard reads as a quiet afternoon. A database deadlock surfaces as an aborted transaction the code must retry. Livelock shows as high processor use and no throughput. Starvation shows as one client, one queue or one kind of request that is always slow while the averages look fine.
Each needs a different signal, and a service with no timeouts anywhere cannot recover from any of them without a restart. The timeout is what turns silence into an error somebody can see.
- "A deadlock would crash or throw." A deadlock is silence: threads blocked, the processor idle, requests timing out upstream. Only a detector or a timeout turns it into an error anyone sees.
- "Our code only takes one lock at a time, so it cannot deadlock." A library call made under your lock that takes its own lock, a callback, or a logging handler with an internal lock adds the second lock invisibly.
- "Timeouts fix deadlocks." A timeout detects a probable deadlock and breaks it by failing one side. Without a lock order the same collision recurs under load, and a retry in the same order at the same moment becomes a livelock.
- "Deadlock needs many threads and complicated code." Two threads and two locks taken in opposite orders are enough, and the window can be microseconds wide, which is why it appears only under production load.
- "Priorities guarantee the important work runs first." A high-priority thread waiting on a lock held by a low-priority one waits for whatever is starving the low-priority one. Priority inheritance exists because this happened on Mars.
- Define one global lock order and acquire locks in it everywhere. Circular wait becomes impossible.
- Never call code you do not control, such as callbacks, plugins or logging handlers, while holding a lock. Their locks join your lock order without asking.
- Put a timeout on every lock acquisition that can wait on another component, and report its expiry as an error instead of hiding it in a silent retry. Silence is the symptom that costs the most.
- Retry conflicts with randomized backoff. Identical retries at identical moments turn a deadlock into a livelock.
Knowledge Check
A transfer function always locks the destination account first and then the source. Which Coffman condition does changing it to "lock the lower account id first" remove?
- Mutual exclusion, since both transfers can now hold both locks together
- Hold and wait, since a thread no longer holds one lock while waiting
- No preemption, since the waiting thread can now take the other's lock
- Circular wait, since every thread now climbs the same order of locks
Service A takes lock X then calls a logging handler that takes lock L. Service code elsewhere takes lock L and then lock X. Can this deadlock?
- Yes: one path takes X then L and the other L then X
- No: the logging handler's lock is internal, so it does not count
- No: deadlock needs at least three locks to form a cycle
- Only if both paths run on the same core at the same moment
A service shows near-zero CPU, a full thread pool and requests timing out. Another shows 100% CPU and near-zero throughput. Which is which?
- The first is livelock and the second deadlock, since livelock is quiet
- Both are starvation, which appears as either low or high CPU use
- The first is deadlock and the second livelock, judging by the CPU pattern
- The first is a network outage and the second is a deadlock between two locks
Two workers detect a conflict, back off for exactly 10 ms and retry. They collide again and again. What breaks the pattern?
- Increasing the backoff to exactly 100 ms for both workers
- Retrying immediately, so that one worker is likely to win first
- Adding a random delay, so the two retries no longer line up
- Adding a third worker, which takes the lock and serializes the other two
A high-priority task waits on a lock held by a low-priority task, while medium-priority tasks keep running. What is happening, and what fixes it?
- Deadlock; fix it by imposing a global lock order on the three tasks
- Livelock; fix it by adding random backoff to the high-priority task's retries
- Priority inversion; fix it by lending the holder the waiting task's priority
- Starvation of the medium tasks; fix it by raising their priority further
You got correct