Deadlock

What a deadlock is

A deadlock is a set of threads (or processes, or transactions), each waiting for a resource held by another member of the set, so that none can ever proceed. Nothing crashes and nothing is logged: the program simply stops making progress, with its threads asleep and CPU usage at zero.

graph LR T1["Thread 1<br/>holds Lock A"] -->|"waits for"| B["Lock B"] B -->|"held by"| T2["Thread 2<br/>holds Lock B"] T2 -->|"waits for"| A["Lock A"] A -->|"held by"| T1

The four necessary conditions

Coffman et al. (1971) showed a deadlock can occur only if all four hold at once:

Condition Meaning Typical way to break it
Mutual exclusion A resource can be held by only one thread Use lock-free or immutable data; rarely possible for everything
Hold and wait A thread holds one resource while waiting for another Acquire all needed locks at once, or release before acquiring more
No preemption A held resource can't be taken away Timeouts / try_lock with back-off; transactions that can be aborted
Circular wait A cycle exists in the "waits for" graph Global lock ordering

Every deadlock-prevention technique breaks one of these. The most practical one, by far, is breaking circular wait.

Prevention 1: a global lock order

If every thread acquires locks in the same global order, no cycle can form: a thread holding a lock can only wait for a lock that comes later in the order, and the last lock in any chain waits for nothing.

// Transfer between two accounts: lock in a canonical order, not "from, then to".
func Transfer(from, to *Account, amount int64) {
    first, second := from, to
    if to.ID < from.ID {
        first, second = to, from
    }
    first.mu.Lock()
    defer first.mu.Unlock()
    second.mu.Lock()
    defer second.mu.Unlock()
    from.Balance -= amount
    to.Balance += amount
}

Without the ordering, Transfer(a, b) and Transfer(b, a) running concurrently deadlock: each takes its from lock and waits for the other's. Ordering by ID makes both take min(a,b) first. A real goroutine dump of the unordered version — two goroutines in [sync.Mutex.Lock] with their account arguments swapped — is in Panics, Stack Dumps, and Delve.

In larger systems, the order is a documented hierarchy of lock levels: "the DB mutex before any cache shard lock; a cache shard lock before nothing." Debug builds can enforce it: each thread records the levels it holds, and acquiring a lock with a level lower than one already held aborts with both stacks. Some lock libraries detect potential lock-order inversions dynamically, even if no deadlock actually occurred in that run (Abseil's Mutex does; in Go, wrappers such as github.com/sasha-s/go-deadlock record lock order in tests).

When no order can be imposed, a goroutine can take the first lock and TryLock the second, releasing both and retrying (with backoff) if the second is busy — sync.Mutex.TryLock exists for such rare cases.

Prevention 2: don't hold locks across unknown code

Most real deadlocks are not two locks taken in visibly opposite order in one file. They involve a lock taken indirectly:

  • calling a callback, a virtual function, or a user-supplied comparator while holding a lock — the callee takes another lock, or calls back into the object;
  • logging while holding a lock, when the logger has its own lock and some other thread logs while holding yours;
  • a destructor run under a lock that acquires a different lock.

The rule: hold a lock only around code you control and can see. Copy what you need, release the lock, then call out. LevelDB's DBImpl releases its mutex before doing I/O or calling into code that might block for exactly this reason.

Self-deadlock

Go's sync.Mutex is not reentrant: a goroutine that locks a mutex it already holds waits for itself forever. It happens when a method that takes the lock calls another method that also takes it. The usual fix is a private fooLocked() variant whose name documents that the caller must hold the lock, called by both. Go deliberately has no recursive mutex: needing one usually means the invariants the lock protects may be temporarily broken when the inner call runs.

Condition variables are another source: waiting on a condition that only this goroutine could ever make true, or a Signal sent before the waiter began waiting without a condition to re-check (a lost wakeup, prevented by always calling Wait in a for !condition loop). With channels, the equivalent is a send that no receiver will ever take, or a receive on a channel nobody will close.

Deadlocks beyond mutexes

The four conditions apply to any resource:

Resource Deadlock example
Thread pool slots A task running on a pool of size N submits subtasks to the same pool and waits for them; when all N workers are waiting, nothing runs the subtasks
Bounded queues / channels Two goroutines each send to a full channel the other should drain
Connection pools Each request holds one connection and needs a second; under load every connection is held by a request waiting for another
Database row locks Two transactions update the same two rows in opposite order
Processes and pipes Parent writes a large input to a child's stdin while the child, blocked writing a large output to a full stdout pipe, waits for the parent to read
Memory Two processes each need more memory than remains to finish and free theirs

The thread-pool case is the most common in storage engines: never block a pool thread waiting for work queued on the same pool. Give dependent work its own pool (RocksDB separates flush and compaction pools), or make the waiting step asynchronous.

Avoidance: Banker's algorithm

Avoidance grants a request only if the system stays in a "safe state" — one where some order exists in which every thread can finish given its declared maximum needs. Dijkstra's Banker's algorithm checks this before each allocation. It requires knowing every thread's maximum resource needs in advance, so it is a textbook concept rather than something general-purpose software uses; resource admission control (refusing a query that might exceed memory) is its practical cousin.

Detection and recovery

When prevention is impractical, detect deadlocks and break them. This is what databases do, because transactions lock rows in an order determined by user queries.

  • Build the wait-for graph: an edge T1 → T2 when T1 waits for a lock T2 holds. A cycle is a deadlock.
  • PostgreSQL checks for cycles only after a lock wait exceeds deadlock_timeout (default 1 s) — most waits resolve on their own, and graph search is costly — then aborts one transaction with ERROR: deadlock detected.
  • InnoDB checks immediately on each lock wait (configurable with innodb_deadlock_detect) and rolls back the transaction with the fewest changes.
  • Many systems instead use lock timeouts: give up after N ms and abort (RocksDB TransactionDB defaults to timeouts; its cycle detection is optional). Simpler, but a timeout cannot distinguish a deadlock from a slow holder.

Recovery means aborting a victim and having the client retry. Applications using database transactions must therefore treat deadlock/serialization errors as retriable. See Locking and Concurrency.

Diagnosing a hung process

A deadlock looks like a hang with zero CPU. Capture every thread's stack:

gdb -p <pid> -batch -ex "thread apply all bt" > stacks.txt    # Linux
lldb -p <pid> --batch -o "bt all" > stacks.txt                # macOS
kill -QUIT <pid>        # Go: dumps all goroutine stacks to stderr
jstack <pid>            # Java: prints stacks and reports found deadlocks

Look for threads blocked in pthread_mutex_lock / __lll_lock_wait / futex_wait, note which lock (the mutex address argument) each waits for and which locks each holds (from the frames above), and draw the cycle. Go's runtime detects the special case where all goroutines are blocked and crashes with fatal error: all goroutines are asleep - deadlock!. ThreadSanitizer reports lock-order inversions — potential deadlocks — even in runs that happened not to hang.

Livelock and starvation

Problem What happens
Livelock Threads are not blocked but keep reacting to each other without progress — two threads that both back off and retry at the same moment, forever. Fix: randomized back-off
Starvation A thread can proceed in principle but never gets the resource: unfair locks, writer starvation in reader-preferring rwlocks, low-priority threads
Priority inversion A high-priority thread waits for a lock held by a low-priority thread, which is preempted by medium-priority work, so the high-priority thread effectively waits for the medium one. Fix: priority inheritance — the holder temporarily runs at the highest waiter's priority (PTHREAD_PRIO_INHERIT). Famous for hanging the Mars Pathfinder lander in 1997

Common pitfall

Only ordering the locks a developer remembers exist. A third, less-obvious lock — a mutex inside a library call, a lock in the logging framework, a database row lock acquired indirectly through an ORM — can participate in the same cycle and still deadlock. Global lock ordering works only if it is genuinely global, covering every lock a code path can acquire. That is why the database version of this problem recommends sorting IDs before locking rather than "remembering the right order," and why systems that take lock ordering seriously enforce it mechanically (lock levels checked in debug builds, Clang thread-safety annotations, TSan's inversion detector) rather than by convention.