WierdX — Programming Reference All tutorials →
Developer reference · Practical tutorials · CS fundamentals
Concurrent Programming

Deadlocks and Race Conditions: Thread Safety Without Fear

Concurrency bugs are among the hardest to reproduce because they depend on exact thread scheduling. Race conditions silently corrupt data; deadlocks silently freeze programs. Both are preventable once you understand the underlying mechanisms.

Published June 30, 2026

Multi-threaded programs appear to run threads simultaneously, but threads share memory. When two threads read and write the same variable without coordination, the result depends on which thread's instructions are scheduled in which order — and the OS scheduler gives you no guarantees. This non-determinism is the source of the two most common classes of concurrency bugs: race conditions and deadlocks.

Race Conditions: The Counter That Lies

A race condition occurs when the program's output depends on the relative timing of thread execution. The canonical example is a shared counter:

import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1  # NOT atomic in Python

threads = [threading.Thread(target=increment) for _ in range(5)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # Should be 500000, but prints something lower

The line counter += 1 looks atomic but is not. It compiles to three operations: read the current value, add 1, write back the result. If Thread A reads 42 and Thread B also reads 42 before Thread A writes back 43, both threads write 43. One increment is lost. With 500,000 intended increments, you might see 487,312 or any other value below 500,000, and it changes with every run.

Fixing Race Conditions with a Mutex

A mutex (mutual exclusion lock) ensures that only one thread can execute a critical section at a time. In Python, use threading.Lock:

import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1  # Now atomic: only one thread runs this at a time

threads = [threading.Thread(target=increment) for _ in range(5)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter)  # Always 500000

The with lock: context manager acquires the lock before entering the block and releases it on exit, even if an exception is raised. Always use the context manager form rather than manual lock.acquire() and lock.release() calls, which are easy to misbalance.

Note that Python's Global Interpreter Lock (GIL) prevents true parallel execution of Python bytecode in a single process, which reduces (but does not eliminate) race conditions on simple operations. For CPU-bound work, switch to multiprocessing, which has its own shared memory primitives.

Deadlocks: When Threads Wait Forever

A deadlock occurs when two or more threads are each waiting for a resource held by another, forming a circular wait. No thread can proceed, so the program freezes with no error message and no forward progress.

import threading

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

def thread_one():
    with lock_a:
        print("Thread 1 acquired A")
        # Imagine some work here
        with lock_b:
            print("Thread 1 acquired B")

def thread_two():
    with lock_b:
        print("Thread 2 acquired B")
        with lock_a:
            print("Thread 2 acquired A")

t1 = threading.Thread(target=thread_one)
t2 = threading.Thread(target=thread_two)
t1.start()
t2.start()
t1.join()
t2.join()
# Hangs: Thread 1 holds A waiting for B; Thread 2 holds B waiting for A

The Four Coffman Conditions

A deadlock can only occur when all four of these conditions hold simultaneously. Eliminate any one condition and deadlock becomes impossible.

  1. Mutual exclusion: At least one resource can only be held by one thread at a time. Locks, file handles, and hardware devices satisfy this condition.
  2. Hold and wait: A thread holds at least one resource and is waiting to acquire additional resources currently held by other threads.
  3. No preemption: Resources cannot be forcibly taken from a thread. The thread must release them voluntarily.
  4. Circular wait: A set of threads T1, T2, ..., Tn exists such that T1 waits for a resource held by T2, T2 waits for T3, and so on until Tn waits for a resource held by T1.

Prevention Strategies

  1. Lock ordering: Always acquire locks in a globally consistent order. If every thread acquires lock_a before lock_b, circular wait is impossible. This is the simplest and most effective technique for most programs.
  2. Try-lock with timeout: Use lock.acquire(timeout=1.0) and release all held locks if acquisition fails. Retry after a random backoff to avoid livelock.
  3. Lock-free data structures: Use atomic compare-and-swap operations or Python's queue.Queue (which handles locking internally) to avoid holding multiple locks at once.
  4. Single lock per subsystem: Coarse-grained locking with one lock per module is slower but eliminates multi-lock deadlocks entirely within that module.
  5. Banker's algorithm (resource allocation graphs): In systems that must dynamically allocate multiple resource types, track which allocations are safe before granting them. Used in OS kernels; impractical for most application code.

Concurrency bugs are not a reason to avoid threads. They are a reason to design thread interaction carefully, keep shared mutable state minimal, and use well-tested synchronization primitives rather than home-grown locking schemes.