Deadlocks
A deadlock needs four things at once — mutual exclusion, hold-and-wait, no preemption, circular wait — and the last is a cycle in the waits-for graph, which is why detection is depth-first search, prevention is imposing an order on the graph, and a database deadlock detector runs the same algorithm on transactions.
The problem
The four ingredients
Coffman’s 1971 conditions are necessary and jointly sufficient. Mutual exclusion: the resource cannot be shared — a mutex, a row lock, a pipe buffer with one reader. Hold and wait: a thread holds one resource while waiting for another. No preemption: the resource cannot be taken away; the holder must release it voluntarily. Circular wait: a chain of threads where each waits for a resource the next one holds, closing back on the first. Remove any one and deadlock is impossible; every prevention technique is a choice of which one to remove.
Mutual exclusion is usually the point (you took the lock because the section needs it), and no preemption is a property of locks (you cannot yank a mutex from a thread mid-update without corrupting what it guards). That leaves hold-and-wait — acquire everything at once, or nothing — and circular wait — impose an order so cycles cannot form — as the two practical levers. Databases pick a third route: allow all four, detect the cycle, and preempt after all by aborting a transaction.
Detection is cycle detection
Draw a waits-for graph: a directed graph with one node per thread and an edge from T1 to T2 when T1 is blocked on a resource T2 holds. A deadlock exists if and only if the graph has a cycle. Detection is therefore a solved problem from the DSA domain — depth-first search with a visited/on-stack colouring (Cycle Detection on a Directed Graph) — run over the lock manager’s bookkeeping. With resources that have multiple instances (a semaphore with N permits) the graph gains resource nodes and the condition becomes "no reduction sequence clears it", but the two-lock mutex case is a plain cycle.
In practice you rarely run the detector yourself; you read its output. The Linux kernel’s lockdep records every lock-acquisition order it has ever seen and reports a *potential* cycle the first time an inconsistent order appears — before the deadlock actually occurs. The JVM’s jstack walks monitor ownership and prints "Found one Java-level deadlock" with both stacks. Go’s runtime panics with "all goroutines are asleep - deadlock!" when nothing can ever run. For a native process the manual version is gdb -p PID, thread apply all bt, find every thread in __lll_lock_wait, read which mutex address each wants, and read from the stack frames who holds it: the cycle is usually two threads long and takes ten minutes to see.
The production signature is a hang at 0% CPU with health checks still passing if they do not touch the locked subsystem (A Taxonomy of Concurrency Bugs). Because nothing errors, the first alert is often a queue backing up or a latency graph going vertical. A watchdog that dumps all thread stacks when a request exceeds a deadline is the cheapest deadlock detector a service can have.
1edges = {} # thread -> set of threads it waits on2for t in threads:3 for lock in t.waiting_for:4 edges[t].add(owner_of(lock))5 6def has_cycle(t, on_stack, done):7 on_stack.add(t)8 for u in edges[t]:9 if u in on_stack: return True # back edge: deadlock10 if u not in done and has_cycle(u, on_stack, done): return True11 on_stack.remove(t); done.add(t)12 return FalsePrevention: ordering, try-lock, timeouts
Lock ordering removes circular wait: assign every lock a rank (by address, by a documented hierarchy, by object id) and always acquire in ascending rank. If every thread obeys, the waits-for graph is acyclic by construction. This is the discipline in the Linux kernel, in every database engine’s internal latches, and in any codebase that has survived a deadlock. Its weakness is that the order must be global and known — a callback or a library that takes its own lock while you hold yours breaks it silently — and that some code genuinely needs to lock two objects whose relative order is only known at runtime (transfer between two accounts).
For the runtime-order case, remove hold-and-wait with try-lock and back off: lock the first, try_lock the second, and if that fails release the first, pause, and retry. std::lock(m1, m2) and std::scoped_lock(m1, m2) implement exactly this deadlock-free acquisition; Java has no built-in equivalent, so it is written by hand with tryLock. Randomised backoff prevents two threads from retrying in lockstep and livelocking (A Taxonomy of Concurrency Bugs). For the transfer example, ordering by account id is simpler and is what most code does.
Timeouts do not prevent deadlock; they convert it into an error. pthread_mutex_timedlock, try_lock_for, acquire(timeout=): after N seconds the waiter gives up, releases what it holds, logs, and either retries or fails the request. That is a legitimate strategy for coarse operations (a request handler, a batch job) and a poor one inside hot paths, where a timeout either fires spuriously under load or is too long to matter. Whatever the strategy, keep the number of locks any one path holds simultaneously small — two is manageable, five is a research project.
- Ordering: kills circular wait; needs a global rank; breaks on hidden locks in callbacks.
- Try-lock + backoff: kills hold-and-wait;
std::scoped_lockdoes it for you; randomise the backoff. - Timeouts: turn a hang into an error; fine for request-scale operations, wrong for microsecond sections.
- Fewest locks per path; never lock in a callback; never lock while holding a lock you did not plan to.
Deadlocks without mutexes
Any resource with the four properties will do. Two processes each writing a large message to the other over a pair of pipes deadlock when both pipe buffers (64 KiB by default on Linux) fill before either starts reading — the classic subprocess.communicate bug when you read stdout to completion before stderr. A thread pool deadlocks when every worker blocks waiting for a task that needs a worker to run: with 8 workers, 8 tasks each submitting a subtask and waiting on it exhausts the pool with nothing runnable. fork() in a multithreaded process copies a heap lock held by another thread into the child, where the holder does not exist; the child’s first malloc waits forever — the reason fork without an immediate exec is unsafe with threads.
The same shape appears across machines: two services each calling the other synchronously with a bounded connection pool, each holding a connection while waiting for the other, is a distributed deadlock that no local detector sees. Timeouts are the only defence there, which is one reason every network call needs one.
The database does the same thing, with recovery
Transactions take row locks and hold them until commit; two transactions updating the same two rows in opposite orders deadlock exactly like two threads. The engine cannot impose an order — the application chose it — so it detects and recovers. PostgreSQL waits deadlock_timeout (1 s by default) on any lock, then runs the waits-for-graph cycle check over all waiting backends; if it finds a cycle it aborts one participant with ERROR: deadlock detected (SQLSTATE 40P01) and releases its locks so the rest proceed. InnoDB checks the graph eagerly at each wait and rolls back the transaction that has done the least work. The aborted client is expected to retry — the database has, in effect, preempted a resource, which mutex-based code cannot do.
Everything transfers. The application-side fix is the same lock ordering: update rows in a consistent order (by primary key) and keep transactions short so hold-and-wait windows are small. The diagnostic is the same graph: pg_locks joined to pg_stat_activity shows who waits on whom, and the deadlock error message prints the cycle. Locks and Deadlocks covers the database side; the point of this lesson is that it is not a different phenomenon, only a different lock manager with a retry contract built in.
ERROR: deadlock detected
DETAIL: Process 41822 waits for ShareLock on transaction 8815; blocked by process 41830.
Process 41830 waits for ShareLock on transaction 8814; blocked by process 41822.
HINT: See server log for query details.
CONTEXT: while updating tuple (0,7) in relation "accounts"Key points
- Deadlock = mutual exclusion + hold-and-wait + no preemption + circular wait, all at once. Every fix removes one of them.
- The waits-for graph has a cycle if and only if there is a deadlock; detection is DFS cycle detection over the lock manager’s state.
- Signature: hang at 0% CPU, every thread in
futex_wait;jstack, Go’s runtime, lockdep andgdb thread apply all btshow the cycle. - Prevention: a global lock order (no circular wait), or try-lock with randomised backoff /
std::scoped_lock(no hold-and-wait). Timeouts turn the hang into an error. - Pipes, thread pools,
forkwith threads and mutual synchronous service calls deadlock without any mutex in sight. - Databases allow the cycle, detect it with the same DFS, and abort one transaction (
40P01) so the others proceed; the client retries.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why can the database preempt and my mutex cannot?
Because a transaction can be rolled back — its writes are undone from the log and it starts over — whereas a thread halfway through a critical section has partially updated memory with no undo. Recoverability is what makes detection-and-abort a valid strategy.
▸Why is lock ordering the default prevention rather than a smarter algorithm?
Deadlock avoidance algorithms like the Banker’s algorithm need to know every thread’s maximum future demand, which general programs cannot state. Ordering needs only a rank per lock and a rule every acquisition follows; it is cheap, local, and checkable by tools like lockdep.
▸Why do deadlocks appear weeks after the code shipped?
The cycle needs both paths to hold their first lock at the same instant, a window of microseconds that a specific concurrent request mix hits rarely. The bug was present on day one; the timing was not.
Deadlock waits-for graph
How it fails
What the failure looks like from inside real software.
- Service hangs at 0% CPU after a burst; every thread is in
futex_wait; two request paths takeaccounts_lockandaudit_lockin opposite orders (process-hang-deadlock). - A
subprocesscall hangs because the child filled its stderr pipe while the parent was blocked reading stdout to EOF. - A thread pool of 8 stalls: 8 tasks each wait on a subtask they submitted to the same pool.
- A forked child hangs on its first allocation: another thread held the allocator lock at fork time.
- PostgreSQL clients see intermittent
deadlock detectederrors with no retry logic; the aborted requests are lost instead of retried. - Two services call each other synchronously with bounded connection pools; under load both pools fill with requests waiting on the other — a distributed deadlock that only timeouts break.