Race conditions cause rare, unreproducible bugs; locks are the basic defence.
When threads share mutable data, operations that look atomic often aren't. counter += 1 is a read, an add and a write. If two threads read the same old value, one increment is lost. That's a race condition: the result depends on timing. With the GIL such races are rarer, but they still happen, because the interpreter can switch threads between bytecodes; on the free-threaded build they're far more likely.
A lock (mutex) makes a block of code exclusive:
lock = threading.Lock()
with lock:
counter += 1 # only one thread at a time in hereRules for using locks well:
- Always use
with lock:rather than manualacquire()/release(), so exceptions can't leave the lock held. - Keep critical sections short; never do slow I/O while holding a lock if you can avoid it.
- Protect every access to the shared state with the same lock, reads included when they must be consistent.
lock.acquire(timeout=1)returns False on timeout, useful to detect deadlocks.
Often the best fix is avoiding shared mutable state entirely: give each thread its own data and combine results at the end, or communicate through a queue.Queue.
Example
import threading, time
counter, lock = 0, threading.Lock()
def unsafe(n):
global counter
for _ in range(n):
value = counter # read
time.sleep(0) # a thread switch here (forced, to show the bug)
counter = value + 1 # write: overwrites other threads' updates
def safe(n):
global counter
for _ in range(n):
with lock:
counter += 1
for fn in (unsafe, safe):
counter = 0
threads = [threading.Thread(target=fn, args=(2_000,)) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(fn.__name__, counter)
# unsafe e.g. 2013 (lost updates; varies per run)
# safe 8000Remember
- Read-modify-write on shared data is a race condition
- with lock: makes a critical section exclusive
- Avoid shared mutable state where you can
Watch out for
- Assuming the GIL makes compound operations like += atomic.