Thread-safety patterns and deadlocks

threading & multiprocessing · Advanced · 12 min

Most concurrency bugs in production are deadlocks, leaked threads and unsafe shared objects.

Deadlock: thread A holds lock 1 and waits for lock 2 while B holds lock 2 and waits for lock 1. Both wait forever. Prevent it by:

  • acquiring locks in a global, consistent order,
  • holding at most one lock at a time when possible,
  • using acquire(timeout=...) and failing loudly,
  • using RLock when the same thread may re-enter its own locked code (a plain Lock deadlocks on itself).

Thread-local storage: local = threading.local(); attributes set on it are private to each thread. Good for per-thread connections or sessions that aren't thread-safe.

What's thread-safe: in CPython, single operations on built-ins such as list.append, dict[key] = value and deque.popleft are atomic, and the free-threaded build keeps this with per-object locks. Compound operations (check-then-act, +=, iterating while another thread mutates) are not. Many libraries document thread-safety; when unsure, assume not.

Shutdown: daemon=True threads are killed abruptly at interpreter exit, possibly mid-write, so use them only for disposable work. For everything else, signal with an Event or queue shutdown and join(). threading.enumerate() lists live threads, and faulthandler.dump_traceback() shows where every thread is stuck.

Example

import threading

a, b = threading.Lock(), threading.Lock()
both_hold_first = threading.Barrier(2)

def transfer(first, second, name):
    with first:
        both_hold_first.wait()                    # each thread now holds one lock
        if not second.acquire(timeout=0.2):       # detect instead of hanging forever
            print(f"{name}: possible deadlock, backing off")
            return
        second.release()

t1 = threading.Thread(target=transfer, args=(a, b, "t1"))
t2 = threading.Thread(target=transfer, args=(b, a, "t2"))   # opposite order!
for t in (t1, t2): t.start()
for t in (t1, t2): t.join()
# t1: possible deadlock, backing off   (t1 then releases a, so t2 completes)
# fix: every thread acquires a before b

local = threading.local()
def per_thread(n):
    local.value = n                               # private to this thread
    print(threading.current_thread().name, local.value)
threading.Thread(target=per_thread, args=(1,), name="A").start()   # A 1

Remember

  • Acquire locks in one global order, or use timeouts to detect deadlocks
  • threading.local() gives each thread its own attributes
  • Prefer joined worker threads over daemon threads for anything important

Watch out for

  • Calling a method that takes the same plain Lock the caller already holds, deadlocking the thread on itself.

Check yourself

  1. Thread 1 locks A then B; thread 2 locks B then A. What's the standard fix?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/threading.html#rlock-objects