โ˜ ๏ธ Pattern 6: Deadlocks

Two threads, two locks, each holding what the other wants โ€” and both waiting forever. No error, no crash, just a program frozen solid. Here's how to reproduce it, understand it, and design it away.

The Classic Two-Lock Deadlock

Scenario: a bank transfer locks both accounts before moving money. Thread A transfers from account 1 โ†’ 2, so it grabs L1 then reaches for L2. Thread B transfers 2 โ†’ 1, so it grabs L2 then reaches for L1. If they interleave just right, A holds L1 waiting on L2, B holds L2 waiting on L1. Neither will ever release. Frozen.
import threading, time

L1 = threading.Lock()
L2 = threading.Lock()

def thread_a():
    with L1:
        print("A acquired L1")
        time.sleep(0.1)          # give B time to grab L2 โ€” forces the race
        with L2:                 # A now waits for L2 (held by B) forever
            print("A acquired L2")

def thread_b():
    with L2:
        print("B acquired L2")
        time.sleep(0.1)
        with L1:                 # B now waits for L1 (held by A) forever
            print("B acquired L1")

a = threading.Thread(target=thread_a)
b = threading.Thread(target=thread_b)
a.start(); b.start()
a.join(); b.join()               # never returns โ€” the program hangs here
The nasty part: no exception, no CPU spike โ€” the threads are asleep, parked by the OS waiting on a lock that will never free. It often only triggers under load or a specific timing, so it passes every test and hangs in production at 3am.
flowchart LR A["Thread A
holds L1"] -->|wants| L2["Lock L2"] B["Thread B
holds L2"] -->|wants| L1["Lock L1"] L2 -->|held by| B L1 -->|held by| A

The Four Coffman Conditions

A deadlock can only happen when all four of these hold at once. Break any one and deadlock becomes impossible โ€” that's the whole toolbox for prevention.

Most practical fixes attack circular wait (impose a lock ordering) or hold-and-wait (acquire everything at once, or use timeouts to back off). You almost never touch mutual exclusion or no-preemption directly.

Fix #1 (Primary): Consistent Lock Ordering

Break the circular wait: if every thread always acquires locks in the same global order, no cycle can form. Give each lock a stable rank (its id(), a name, an index) and always take the lower-ranked one first.

import threading

L1 = threading.Lock()
L2 = threading.Lock()

def acquire_ordered(first, second):
    # sort the two locks by a stable key so EVERY thread agrees on the order
    lo, hi = sorted((first, second), key=id)
    return lo, hi

def transfer(from_lock, to_lock):
    lo, hi = acquire_ordered(from_lock, to_lock)
    with lo:
        with hi:                 # both threads take id(lo) first โ†’ no cycle
            ...                  # move the money

# both directions now acquire in the same physical order
threading.Thread(target=transfer, args=(L1, L2)).start()
threading.Thread(target=transfer, args=(L2, L1)).start()
This is the standard fix. The bug in the classic example is precisely that A and B acquired in opposite orders. Force one global order and the circular wait is structurally impossible โ€” regardless of timing.

Fix #2: acquire(timeout=...) to Fail Fast

When a global ordering is impractical, break hold-and-wait: don't wait forever for the second lock. Give up after a timeout, release what you hold, back off, and retry. This trades a guaranteed hang for occasional wasted work.

import threading, time, random

L1 = threading.Lock()
L2 = threading.Lock()

def transfer():
    while True:
        with L1:                          # hold the first lock
            got = L2.acquire(timeout=0.05)   # but DON'T block forever on the second
            if got:
                try:
                    ...                   # do the work with both locks
                    return
                finally:
                    L2.release()
        # failed to get L2: L1 is now released (with block exited).
        # back off a random amount to avoid re-colliding, then retry.
        time.sleep(random.uniform(0, 0.05))
Careful: naive retry-on-timeout can turn into a livelock โ€” two threads politely retrying in lockstep, both failing forever. The random back-off is what breaks the symmetry. Lock ordering (Fix #1) is cleaner when you can do it; timeouts are the escape hatch when you can't.

Fix #3: Avoid Nested Locks Entirely

The safest deadlock fix is to not create the condition in the first place. If a thread never holds two locks at once, hold-and-wait can't happen.

Rule of thumb: the number of deadlock opportunities grows with the number of locks a thread can hold simultaneously. Zero-or-one nested locks โ†’ zero deadlocks by construction.

RLock Cures Self-Deadlock, Not Cross-Thread Deadlock

A common confusion: a thread deadlocking on itself (re-acquiring a plain Lock it already holds โ€” see Pattern 2) is fixed by an RLock. But that is a different problem. An RLock does nothing for the two-thread circular wait above.

import threading

rlock = threading.RLock()
with rlock:
    with rlock:      # โœ… fine: same thread re-enters its OWN lock
        ...

# But between TWO threads holding TWO RLocks in opposite order,
# you still deadlock exactly as with plain Locks. RLock only relaxes
# re-entrancy for the owning thread โ€” not the circular-wait relationship.
Don't reach for RLock expecting deadlock immunity. It only lets a thread re-acquire a lock it already owns. Cross-thread deadlocks need lock ordering, timeouts, or fewer locks โ€” not a re-entrant lock.

The Best Fix: Don't Share Locks โ€” Share a Queue

Most deadlocks come from threads reaching into shared mutable state guarded by multiple locks. A lock-free design sidesteps the whole category: hand data between threads through a queue.Queue instead of sharing memory behind locks. The queue owns its single internal lock, and no application code holds two locks at once.

import queue, threading

tasks = queue.Queue()

def worker():
    while True:
        item = tasks.get()      # thread-safe; no application-level locks at all
        if item is None:
            break
        ...                     # process item, no shared mutable state
        tasks.task_done()

threading.Thread(target=worker, daemon=True).start()
for i in range(100):
    tasks.put(i)
tasks.join()                    # wait for all items to be processed
The hierarchy of preference: (1) no shared state / message-passing via Queue โ†’ no deadlock possible. (2) One lock. (3) Multiple locks with a strict global ordering. (4) Multiple locks with timeouts and back-off. Only descend the list when the level above genuinely doesn't fit.

Key Takeaways

ConceptTakeaway
DeadlockCycle of threads each holding a lock the next one wants โ€” freezes silently
Coffman conditionsBreak any one of the four and deadlock is impossible
Lock orderingPrimary fix โ€” always acquire locks in one global order
acquire(timeout=)Fail fast, release, back off randomly, retry
RLockFixes self re-entry only โ€” NOT cross-thread deadlock
queue.QueueMessage-passing avoids multi-lock deadlocks entirely