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
RLockwhen the same thread may re-enter its own locked code (a plainLockdeadlocks 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 1Remember
- 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.