AlgoMaster Logo

Deadlocks

Medium Priority19 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

A deadlock is when two or more threads are stuck waiting for each other, with no way out. The program doesn't crash, doesn't raise, doesn't print a helpful error. It hangs. Threads that should be doing useful work sit blocked on locks they'll never get, because the threads that hold those locks are themselves blocked. This lesson covers the classic two-lock deadlock, the four conditions that make any deadlock possible, the standard ways to prevent them, and how to detect a deadlock when one shows up in production code.

The Classic Two-Lock Deadlock

The simplest deadlock involves two threads and two locks. Thread A acquires lock 1 and then wants lock 2. Thread B acquires lock 2 and then wants lock 1. Each one is waiting for the lock the other holds, and neither will release until it gets the lock it wants.

Consider an e-commerce app where one operation transfers a balance between two customer accounts, and another operation moves stock between two warehouses. Both operations need to lock two things at once.

Both threads are still alive after the join timeouts because neither can make progress. Thread A is blocked inside with customer_lock:, waiting for B to release. Thread B is blocked inside with order_lock:, waiting for A to release. Neither will release until it gets the lock it wants. The program is stuck.

Thread A grabs order_lock. Thread B grabs customer_lock. Thread A tries to grab customer_lock and blocks because B holds it. Thread B tries to grab order_lock and blocks because A holds it. Neither lock will ever be released, because each holder is blocked waiting on the other.

This kind of bug is easy to write by accident. Each thread, on its own, is straightforward and correct. Both follow the obvious pattern of "lock what's needed, do the work, then release." The deadlock only emerges when both run at the same time and happen to grab their first locks before either tries for the second. Many runs of the program might work fine; the deadlock only shows up under specific timing.

The Four Conditions for Deadlock

Every deadlock, in any language, on any operating system, requires four conditions to be true simultaneously. These are known as the Coffman conditions, named after Edward Coffman who formalized them in 1971. If any one of them is missing, deadlock is impossible.

1. Mutual exclusion. A resource can be held by only one thread at a time. Locks have this by design; a locked resource can't be locked again.

2. Hold and wait. A thread holds at least one resource while waiting to acquire another. If threads released their existing locks before asking for new ones, they couldn't be holding lock 1 while waiting for lock 2.

3. No preemption. A resource can be released only by the thread holding it. The OS, or another thread, can't take a lock away from its owner. Python locks have this property: release() must be called from the holding thread (for RLock) or any thread (for Lock, though releasing from a different thread is bad practice).

4. Circular wait. There exists a cycle of threads where each is waiting for a resource held by the next thread in the cycle. In the two-lock example, A waits for B, B waits for A. With three threads, A waits for B, B waits for C, C waits for A. The cycle can be any length.

ConditionWhat it meansWhy removing it prevents deadlock
Mutual exclusionOnly one thread at a timeIf resources are shareable, no thread blocks waiting
Hold and waitHold one lock while waiting for anotherIf threads acquire everything at once or release before requesting more, no holding
No preemptionLocks can't be taken awayIf the OS could break a deadlock by revoking a lock, the cycle breaks
Circular waitA cycle of waiting threads existsIf there's no cycle, someone can always make progress

In practice, mutual exclusion and no preemption are baked into the design of locks and can't be removed. So the standard defenses target hold-and-wait and circular wait. The most common attack is on circular wait, through consistent lock ordering.

Prevention Strategy 1: Lock Ordering

If every thread acquires its locks in the same global order, no circular wait can form. A cycle requires some thread to hold a "higher" lock while waiting for a "lower" one, but if every thread takes the lower lock first, that situation can't happen.

The fixed version of the earlier example has both threads acquire order_lock first:

When one thread holds order_lock, the other waits for it instead of grabbing customer_lock and creating a cycle. The two threads now serialize on order_lock, which is the desired behavior when they both need both resources.

For a small number of named locks, the ordering is a convention to document and enforce: "always acquire order_lock before customer_lock." For a larger set of locks (one per customer ID, for example), a real ordering function is needed. id() of the lock object works, or any deterministic total order over the lock collection:

The first transfer moves money from alice to bob; the second from bob to alice. Without the sorting, the two threads could acquire locks in opposite orders and deadlock. With sorted(..., key=lambda a: id(a.lock)), both threads acquire the lower-id lock first, regardless of which account is the source or destination. No cycle, no deadlock.

The diagram contrasts the two situations. With opposite orders, deadlock is possible. With the same order, the worst case is that one thread waits for the other to finish, which is normal contention.

Lock ordering is the cheapest deadlock defense to implement. It's also the most robust: once code follows a consistent order, the deadlock category goes away entirely. The downside is that the order has to be followed every time. Code review and a comment near the locks help. So does documenting the order explicitly:

Prevention Strategy 2: Timeouts

When a consistent order isn't possible, the next best defense is to refuse to wait forever. Lock.acquire(timeout=...) returns False if it can't get the lock in time, which lets the thread back off, release what it already holds, and try again later.

When a thread can't acquire its second lock within the timeout, it releases the first lock and sleeps for a random short interval before trying again. The random back-off matters: if both threads back off and retry at the same instant, they'll deadlock again. Adding random jitter makes the retry pattern asymmetric.

Timeouts are less robust than lock ordering. Under heavy load, threads can spend more time retrying than doing actual work, and the random back-off doesn't guarantee progress (a thread can theoretically lose the race repeatedly). They're a reasonable fallback when a consistent order isn't achievable (third-party code, locks created dynamically), but lock ordering is the better first choice.

Timeouts add latency on the contended path. Every failed acquire wastes the timeout duration. A 500-millisecond timeout that fails twice has already cost a full second of wall-clock time, even before the actual work runs. Tune the timeout to be slightly longer than the legitimate hold time, not generous.

Prevention Strategy 3: Try-Locks

A try-lock is a non-blocking acquire: lock.acquire(blocking=False) returns True if it grabbed the lock immediately, False if the lock was already held. This expresses "if both locks are available right now, do the work; otherwise skip or retry."

The non-blocking acquire means a thread never holds one lock while waiting for another. If it can't get both at once, it releases the first one immediately and tries again from scratch. The pattern is sometimes called the "all-or-nothing" acquire.

Try-locks are useful when the work is optional or the caller can retry at a higher level (a web request handler that can return "busy, try again" to the client). They're not a great fit for work that must complete, because the number of retries can't be bounded. For work that has to happen, lock ordering or timeouts are usually the better choice.

Prevention Strategy 4: Avoid Nested Locks

The best deadlock defense is to not need two locks at once. If code can be restructured to hold at most one lock at a time, deadlock becomes structurally impossible. This often requires rethinking the design rather than tweaking the acquisition pattern.

Two examples of what that looks like in practice.

Example 1: Split the data so each lock protects a different concern. Consider one big lock around a Cart and another around an Inventory, with adding an item to the cart requiring an inventory check. One option is to read the inventory snapshot first (with only the inventory lock), then update the cart (with only the cart lock):

Each operation grabs at most one lock at a time. The deduct happens atomically inside the inventory lock; the cart update happens atomically inside the cart lock. No thread ever holds both locks, so no deadlock is possible. The cost is that the inventory and cart can briefly disagree (the inventory is decremented before the cart is updated), but that's usually an acceptable trade-off, and it's easier to reason about than the cross-lock alternative.

Example 2: Use higher-level primitives. Standard library tools like queue.Queue and concurrent.futures already encapsulate the locking. Work is handed off through a clean API, and the locking happens inside, where it's already debugged.

queue.Queue internally uses a Condition to coordinate put and get between threads, but the caller never deals with locks. There's no nested locking, no acquisition order to remember, no deadlock to worry about (within the queue itself; deadlocks can still occur if Queue is mixed badly with custom locks).

The general principle: deadlocks happen at the boundaries where code holds multiple locks. Push the locking inside well-tested abstractions and the deadlock risk usually goes with it.

Livelock and Starvation

Two cousins of deadlock that don't fit the strict definition but cause similar pain.

Livelock is when threads are actively running but not making progress. They keep changing state in response to each other, like two people in a corridor stepping aside in the same direction over and over. A classic example is two threads with try-locks plus retries that always retry at the same instant:

If both threads happen to retry in perfect lockstep, they keep grabbing their first lock, failing on the second, releasing, sleeping, and starting over forever. CPU is spinning; no thread is blocked; no progress is made. The classic fix is randomized back-off, which makes the retry intervals diverge:

Starvation is when one or more threads make progress but a particular thread doesn't, because it keeps losing the race for a lock. A high-priority writer and many readers, for example, can starve the writer if readers always slip in just before the writer's turn. Python's Lock is FIFO-ish in practice on most platforms, so simple starvation is rare, but it can show up in custom synchronization patterns.

SymptomWhat's happeningCommon fix
DeadlockAll involved threads blocked, no CPU activityConsistent lock ordering, timeouts, fewer nested locks
LivelockThreads running, no progress, CPU activeRandomized back-off, exponential back-off
StarvationOne thread never advances while others doFair locks (e.g., queue.Queue ordering), priorities

Most of the time, "the program is hung" turns out to be a deadlock. CPU at 100% but nothing finishing usually means livelock. CPU split among threads but one thread sitting at zero progress means starvation. The right fix depends on which one applies.

Detecting Deadlocks

A deadlock in production is silent. The program doesn't crash, doesn't log anything special. The only symptom is that requests stop completing, or a worker stops producing output. Detecting and diagnosing one comes down to looking at what every thread is currently doing.

The simplest tool is faulthandler.dump_traceback_later, which schedules a traceback dump of every thread after a delay. If the program is healthy, the dump never happens, because the program exits cleanly. If the program is stuck, the dump shows where each thread is blocked.

The dump shows every thread's current call stack. task_a is inside the with lock_b: line, waiting on the lock acquire, and task_b is inside with lock_a:, also waiting. That's enough to identify the deadlock and the two locks involved.

For a deeper look at a live process, on Linux and macOS attach with py-spy:

py-spy is a separate tool (install with pip install py-spy) that prints the current Python call stack of every thread in a running process, without modifying the program. It's especially useful when the deadlock only shows up in production and can't be reproduced locally.

A simpler in-process technique is to log every lock acquire and release, with the thread name and lock identity. The pattern often jumps out in the log: the same lock acquired by two different threads in opposite order. This logging is too expensive to leave on in production, but it's a useful debugging mode that can be enabled temporarily.

The right strategy depends on the situation:

SituationBest tool
Local development, can reproducefaulthandler.dump_traceback_later, breakpoint debugger
Live production processpy-spy dump --pid
Recurring but hard to reproduceLog acquisitions and releases with thread/lock identity, look for cycles
Architectural reviewCode search for nested with lock: blocks

The fastest fixes come from finding the pattern in the code, not from runtime tools. Two with lock_x: blocks that can be entered in opposite orders by different threads is the deadlock recipe. The runtime tools point at the line.

A Real-World Example: Two Carts Sharing a Coupon

A more grounded scenario: an e-commerce app has shoppers who can apply a shared discount coupon to their cart. The coupon has a limited supply ("100 uses left") and the cart tracks its own total. Updating either one needs a lock. A "use coupon" operation needs both: it deducts from the coupon and updates the cart's total.

A naive implementation might lock the cart first, then the coupon:

Two carts in the same app, two different orderings, classic deadlock. CartA.apply takes the cart lock first; CartB.apply takes the coupon lock first. If both run concurrently, each one holds its first lock and waits for the other forever.

The fix is to pick one order and stick to it across every method that touches both locks:

Both carts apply the discount and exit cleanly. Both methods take coupon.lock first, so no thread can hold the coupon while another holds it from the other direction. The pattern works because the coupon is the shared resource; locking it first regardless of which cart is involved is the sensible choice.

An important detail: the rule is "always acquire coupon.lock first." That has to hold for every method on every class, not just apply. If some other method on Cart locks self.lock first and then needs the coupon lock for any reason, the same deadlock returns. Lock ordering rules apply program-wide.

Putting It Together

A deadlock is what happens when threads can wait on each other in a cycle and no one can break out. The recipe is four conditions: mutual exclusion, hold-and-wait, no preemption, and circular wait. Take any one away and deadlock becomes impossible. The cheapest attack is on circular wait, through a consistent lock-acquisition order across every code path that holds more than one lock. When an order isn't possible, fall back to timeouts or try-locks, and accept the cost of retries. The deepest fix is structural: arrange code so it holds at most one lock at a time, often by pushing the synchronization into a higher-level primitive like queue.Queue.

When a deadlock does sneak in, faulthandler.dump_traceback_later and py-spy dump are how to find it in a live process. The output points at the locked region, and the fix is almost always changing the order, removing a nested acquire, or replacing the hand-rolled coordination with something from the standard library.

Quiz

Deadlocks Quiz

10 quizzes