Skillversitas

Common Pitfalls and Deadlocks

Concurrency bugs are mostly a small set of recurring patterns: deadlocks from lock ordering, starvation from holding locks too long, uncaught thread exceptions, and - in asyncio - the single worst bug of all, a blocking call inside the loop. This chapter catalogs them with the fix for each.

Deadlocks

Two threads each hold a lock and wait for the other's: neither can proceed. The classic shape is inconsistent lock ordering:

import threading

lock_a = threading.Lock()
lock_b = threading.Lock()

def t1():
    with lock_a:
        with lock_b:      # A then B
            pass

def t2():
    with lock_b:
        with lock_a:      # B then A  - deadlock with t1
            pass

Fixes, in order of preference: eliminate the second lock (one lock per shared object, owned by that object), impose a global lock ordering and always acquire in that order, or use non-blocking acquisition with a retry/fallback. If a deadlock is already in production, faulthandler.dump_traceback() on a stuck process shows exactly which threads hold which locks.

Other Lock Mistakes

  • Self-deadlock: a plain Lock acquired twice by the same thread (fixed by RLock or restructuring).
  • Forgotten release: an exception between acquire() and release() - always use with or try/finally.
  • Lock around I/O: sleeping, socket, or join() inside a critical section serializes everything behind it.
  • Copy-pasted lock scope: the locked region is smaller (or bigger) than the invariant actually requires.

Silent Thread Deaths

An uncaught exception in a thread prints a traceback and kills only that thread; the main program continues with half-initialized state. Catch inside the target and record the error (the Worker.error pattern from the thread-creation chapter), or route work through concurrent.futures where exceptions surface via future.result().

asyncio Pitfalls

  • Blocking the loop: time.sleep, requests.get, synchronous DB calls freeze all tasks. Replace with async equivalents or asyncio.to_thread.
  • Forgetting await: session.get(url) without await/async with creates a coroutine that never runs (and a RuntimeWarning if it is GC'd).
  • One session per request: creating a ClientSession per call loses the connection pool and can exhaust file descriptors; create one per program or worker.
  • Unbounded tasks: spawning one task per URL without a semaphore can open thousands of connections and crash the target; cap in-flight work.
  • Swallowing CancelledError: except Exception does not catch it (good), but except BaseException: pass breaks cancellation semantics - re-raise it.

Debugging Tools

python -X dev your_app.py        # development mode: more warnings
python -m faulthandler -t        # dump thread stack traces after 30s stall
pip install aiodebug             # asyncio loop error tracking

Summary

Order your locks (or better, reduce the number of locks), release with with, make thread exceptions visible, and in asyncio never block the loop, always await, and bound task fan-out. When in doubt, dump thread stacks - the deadlock is usually staring at you in two frames.