How do you handle race conditions and use locks in threaded Python code?
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 (recursive functions or nested methods that both need the lock, for example) without deadlocking itself. A plainLockwould deadlock in that case.Semaphore(n): allows up tonthreads to hold it simultaneously. Useful for capping concurrent access to a limited resource, like 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()). This is 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.
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.