How do you handle race conditions and use locks in threaded Python code?

6 minadvancedconcurrencythreadinglocksrace-conditions

Quick Answer

A race condition happens when multiple threads read-modify-write shared state without synchronization, so the GIL alone does **not** prevent it — the GIL only serializes individual bytecode instructions, not multi-step operations like `x += 1` (which is read, add, store as separate steps). Use `threading.Lock` (or `RLock`, `Semaphore`, `Condition`) to make a critical section atomic, typically via the lock as a context manager (`with lock:`).

Detailed Answer

Why the GIL doesn't prevent race conditions

import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1   # NOT atomic: read counter, add 1, write back -- 3 separate steps

threads = [threading.Thread(target=increment) for _ in range(4)]
[t.start() for t in threads]
[t.join() for t in threads]
print(counter)   # often less than 400,000 -- lost updates!

counter += 1 compiles to multiple bytecode instructions (load, add, store). The GIL guarantees each individual bytecode instruction runs atomically, but a thread can be swapped out between those instructions — so two threads can both read the same value before either writes back the incremented result, and one increment gets lost.

Fixing it with a Lock

counter = 0
lock = threading.Lock()

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

threads = [threading.Thread(target=increment) for _ in range(4)]
[t.start() for t in threads]
[t.join() for t in threads]
print(counter)   # always exactly 400,000

with lock: acquires the lock on entry and releases it on exit (even if an exception occurs) — while held, no other thread can enter a block guarded by the same lock, making the read-modify-write sequence atomic with respect to other threads using that lock.

Other synchronization primitives

  • RLock (reentrant lock): the same thread can acquire it multiple times (e.g., recursive functions or nested methods that both need the lock) without deadlocking itself; a plain Lock would deadlock in that case.
  • Semaphore(n): allows up to n threads to hold it simultaneously — useful for capping concurrent access to a limited resource (e.g., at most 5 concurrent connections to a rate-limited service).
  • Condition: lets threads wait for some condition to become true, signaled by another thread (.wait()/.notify()) — the basis for producer/consumer patterns.
  • Event: a simple flag threads can wait on until another thread sets it (.set()), useful for one-shot "start now" or "shutdown" signals.

Avoiding locks altogether: prefer thread-safe data structures/patterns

from queue import Queue

q = Queue()   # thread-safe by design -- internally uses its own locking

def producer():
    q.put("item")

def consumer():
    item = q.get()

queue.Queue is thread-safe internally, so a producer/consumer pattern built around it needs no manual locking at all — preferring built-in thread-safe structures (Queue, queue.LifoQueue) over hand-rolled locking is usually safer and simpler than reasoning about locks directly.

Interview-ready summary: The GIL makes individual bytecode instructions atomic, but not multi-instruction operations like x += 1 — race conditions are still possible and must be guarded with threading.Lock (or a higher-level primitive like Queue) around any critical section that reads and writes shared mutable state.

Related Resources