ConcurrencydeadlockCoffman conditionswaits-for graphcycle detectionlock ordering

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.

ConceptualLinuxC++
▶ InteractiveInterview question
Progress

The problem

Thread A holds lock 1 and asks for lock 2. Thread B holds lock 2 and asks for lock 1. Both are correct programs by themselves. Together they will wait forever, at 0% CPU, and no timeout in either will fire because neither thought it needed one.

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.

The smallest deadlock
wantsheld bywantsheld byThread A holds lock 1Lock 2Thread B holds lock 2Lock 1
UserLLMAgentToolDataDecisionHumanGuardrail

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.

Waits-for graph + DFS: the detector in a database lock manager or a debugger script
1edges = {} # thread -> set of threads it waits on
2for 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: deadlock
10 if u not in done and has_cycle(u, on_stack, done): return True
11 on_stack.remove(t); done.add(t)
12 return False

Prevention: ordering, try-lock, timeouts

C++

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_lock does 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

Unix-style

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.

PostgreSQL reporting the cycle it found and the transaction it chose to abort
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 and gdb thread apply all bt show 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, fork with 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

Deadlock as a graph
Threads and locks are nodes; “holds” and “waits for” are edges. A deadlock is a cycle — the same cycle detection as in the DSA graph module.
T0T1L0L1
— holds (lock → thread)┄ waits for (thread → lock)
Pick a thread, then acquire locks. Or load the classic preset.
Mutual exclusion — a lock has at most one holder — inherent to locks
Hold and wait — a thread keeps what it has while waiting for more
No preemption — locks cannot be taken away — unless you time out
Circular wait — a cycle in the waits-for graph
All four (Coffman) conditions are needed. Lock ordering removes circular wait by construction; timeouts remove no-preemption; acquiring everything up front removes hold-and-wait.
Conceptual

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 take accounts_lock and audit_lock in opposite orders (process-hang-deadlock).
  • A subprocess call 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 detected errors 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.