Heads up: posts on this site are drafted by Claude and fact-checked by Codex. Both can still get things wrong — read with care and verify anything load-bearing before relying on it.
why → how

Why deadlocks need four conditions

A deadlock feels like bad luck, but it can only happen when four specific conditions all hold at once — and breaking any one makes it impossible.

Systems intermediate May 14, 2026 · updated Aug 25, 2026 · 10 min read

On this page

The picture version

Five pictures for a reader who has never debugged a hung program, following two threads and two locks — the code version of two people stuck in a doorway.

1 · The problem

Everyone is waiting politely, forever.

Thread A Thread B Lock R1 Lock R2 A waits for what B holds B waits for what A holds held by held by Follow the arrows and you come back to where you started. no crash, no error, no malformed input — every thread is doing exactly what it was told, which is why it looks like bad luck
The loop is the whole bug. Nothing here is broken; the arrows simply close, and a closed loop of “waiting for” has no member that can move first.

2 · The trap

Making it rarer feels like fixing it.

because it presented as a timing fluke, the first fix aims at timing add a small random sleep · stagger the threads · shrink the pool → the hang goes nightly to monthly And every arrow in the picture before is still drawable. you changed the odds that the schedule draws them; you did not remove a single one a fix that improves your metrics and leaves the bug fully intact the timeout version is the same shape: give up after five seconds, and the loop can still form — unless the thread also drops what it holds, in which case you have stumbled into a real fix, five seconds late
Both instinctive fixes aim at when the threads interleave, and interleaving was never the cause. Timing decides when the bug bites, not whether it exists — which is why the cure has to be structural.

3 · The structure

Four walls, and the deadlock needs all of them standing.

mutual exclusion the lock can be held by only one thread hold-and-wait a thread keeps R1 while blocking on R2 no preemption nothing can take R1 back from A circular wait the loop from the first picture These are joined by AND, not OR. all four are necessary, so the deadlock is not unlikely without one of them — it is impossible and with single-instance resources — one lock, one holder — the four are also enough to guarantee it
This is the payoff of treating deadlock as structure rather than luck: the cause is a closed list. Four conditions, all required at once, which turns a debugging problem into a design one.

4 · The cure

You never have to beat the deadlock. Only one of its four ingredients.

kill mutual exclusion — immutable data or per-thread copies; there is nothing left to wait for kill hold-and-wait — take every lock you will need at once, or none of them kill no preemption — drop what you hold and retry; a database does the forcible version by aborting kill circular wait — rank the locks and always take them in order, R1 before R2 Under a global order the loop cannot close. a cycle would need some thread holding the higher-ranked lock while waiting on the lower one, and the rule forbids exactly that no detector, no timeout, no retry loop — just a convention, which is why ordering is the usual pick
All four cures are the same move: make one condition false. There is no fifth option, and no amount of careful timing counts as a cure — you simply choose whichever wall is cheapest to knock out in your system.

5 · Keep this card

The whole thing on one index card.

deadlock = mutual exclusion + hold-and-wait + no preemption + circular wait ∴ they are an AND, not an OR… … so break any one and the deadlock cannot form a program can carry all four latently for years — luck only decides when the schedule lands, never whether the structure is there
Picture to keep: the two people wedged in the doorway — each holding half the gap, each unable to pass, each unwilling to step back. Where it breaks: real people eventually get embarrassed and one backs out. Threads have no embarrassment, so somebody has to design the backing-out in.

Why it exists

Two people reach a narrow doorway from opposite sides at the same moment. Each steps half-in, then stops — neither will back out, because the other might take the gap. They stand there. Nothing is broken; both are behaving “correctly.” They’re just stuck, forever, waiting on each other.

That’s a deadlock, and it’s the bug that makes concurrent programs feel cursed. Your service runs fine for weeks, then one night two threads freeze, requests pile up, and the stack traces show everyone politely waiting — no crash, no error, no obvious culprit. It looks like cosmic bad luck: a one-in-a-million timing fluke.

It isn’t luck. A deadlock is not a random event that sometimes happens when threads share resources. It is a precise structural situation, and it can only occur when four specific conditions are all true simultaneously. These are usually called the Coffman conditions. Miss any one of the four, and a deadlock is not unlikely — it is impossible. That’s the satisfying part: deadlock has a closed-form cause, so it has a closed-form cure.

Why it matters now

Every system that lets more than one thing run at once and share something — threads sharing a mutex, transactions sharing database rows, microservices sharing a connection pool, distributed nodes sharing a lock service — can deadlock. Databases take this seriously enough to ship a deadlock detector: in PostgreSQL, once a transaction has waited on a lock longer than deadlock_timeout, the system checks for a cycle of transactions waiting on each other’s locks and aborts one of them to break it. The reason that detector exists, and the reason it works, is the four-condition structure below. Knowing the four conditions turns “our service hangs sometimes, restart it” into “condition X holds here — break it on purpose.”

The short answer

deadlock = mutual exclusion + hold-and-wait + no preemption + circular wait

Picture to keep: the two people wedged in the doorway — each holding half the gap, each unable to pass, each unwilling to step back. The picture breaks in one useful way: real people eventually get embarrassed and one backs out. Threads have no embarrassment, so somebody has to design the backing-out in.

A deadlock happens exactly when: resources can’t be shared (mutual exclusion), a holder can request more while keeping what it has (hold-and-wait), nobody can forcibly take a resource away (no preemption), and there’s a closed loop of “A waits on B waits on … waits on A” (circular wait). All four are necessary. In the common case of single-instance resources — one lock, one holder — they’re also sufficient: a wait-for cycle means everyone in it is stuck. (With pools of interchangeable resources the cycle test gets weaker, but the four conditions still all have to hold.) Either way, you don’t fix a deadlock by getting lucky with timing — you fix it by designing one of the four conditions out.

How it works

Take the doorway again, but in code. Thread A grabs mutex R1 and then needs R2. Thread B grabs R2 and then needs R1. Each holds one, each waits for the other. Draw the wait-for graph and every arrow leads to the next, all the way around:

flowchart LR
    A[Thread A] -->|waits for| R2[Lock R2]
    R2 -->|held by| B[Thread B]
    B -->|waits for| R1[Lock R1]
    R1 -->|held by| A

Follow the arrows: A waits for R2, which B holds; B waits for R1, which A holds — and you’re back at A. The loop never opens.

Naive fix: make it rarer. This is where most people start, because the bug presented as a timing fluke. Add a small random sleep before acquiring, stagger the threads, reduce the pool size. It works — the hang goes from nightly to monthly — and it fixes nothing. Every arrow in that diagram is still drawable; you’ve only made the schedule that draws them less likely. This is the trap: a fix that improves your metrics while leaving the bug fully intact.

Second naive fix: add a timeout. Give up on a lock after five seconds and return an error. This one is closer to real — and it’s worth being precise about why. If the timing-out thread also drops the locks it was holding and retries, you’ve stumbled into an actual structural fix: you’ve made resources preemptible, just crudely and after a five-second stall. If it keeps holding them while it retries, you’ve built a slower loop around the same deadlock. Either way the cycle can still form, so you keep paying five seconds every time it does — and nothing in the design says how often that is.

The reason the first fix fails outright and the second only half-works is that both are aimed at when the threads interleave, and interleaving was never the cause. The cause is structural, and the structure has exactly four load-bearing walls. Walk them against the diagram and each becomes a place to attack:

The lock-ordering trick is a common move, because it’s cheap and local: you don’t need a detector, a timeout, or a retry loop, just a convention. The deeper point is that all four cures are the same move — make one of the four conditions false. There’s no fifth option, and no amount of careful timing counts as a cure, because timing was never the cause.

One honest seam: “necessary and sufficient” is about the structure, not about whether the bad interleaving actually occurs. A program can have all four conditions latent in its design and run for years because the unlucky schedule never lands. That’s why deadlocks feel like luck — the structure is always there, and luck only decides when it bites. Removing a condition removes the structure, so luck stops mattering.

You started with deadlock = mutual exclusion + hold-and-wait + no preemption + circular wait. What did walking the doorway add to that list of four? — + they're an AND, not an OR. That’s the whole payoff: because all four are necessary, you never have to defeat the deadlock, only one of its four ingredients — and you get to pick whichever one is cheapest to design away in your system.

Check yourself

Before you go — your service has a deadlock between two locks, and a colleague proposes: “let’s use a single global lock for everything instead.” Does that fix it? What did you just pay?

Answer

Yes, it genuinely fixes it — and it’s worth seeing which condition it kills. With one lock, a thread can never hold one resource while waiting for another, so hold-and-wait is gone; there’s also nothing for a cycle to route through, so circular wait is gone with it. This is a real, principled fix, not a hack. The bill is concurrency: every thread now serializes on that one lock even when they’d have touched completely unrelated data. It’s the standard trade — deadlock-freedom bought with throughput — which is why lock ordering is usually preferred: it kills circular wait without collapsing your parallelism.

And one more — two transactions deadlock in Postgres, and the database aborts one with a “deadlock detected” error. Which of the four conditions did the database break, and why did it choose that one?

Answer

It broke no preemption: the victim’s locks are taken away by force when its transaction is aborted. The interesting part is why that’s the lever the engine reaches for. Mutual exclusion on a row it’s writing is the point of the lock. Hold-and-wait is hard to eliminate because the engine can’t know in advance which rows your transaction will touch — you send statements one at a time. And circular wait is broken by acquiring resources in a consistent order, which is a decision your application makes, not the engine; PostgreSQL’s own documentation recommends exactly that, and it’s the better fix when you can arrange it. Preemption is the condition the engine can act on unilaterally, and it’s survivable precisely because transactions already have rollback — “take it all back” is a move the system already knows how to make. So the division of labour is: applications prevent by ordering; the engine detects and preempts for everything the applications didn’t.

Going deeper