Race conditions and Lock

threading & multiprocessing · Essentials · 10 min

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 here

Rules for using locks well:

  • Always use with lock: rather than manual acquire() / 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 8000

Remember

  • 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.

Check yourself

  1. Why can four threads each doing counter += 1 a million times end below 4,000,000?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.python.org/3/library/threading.html#lock-objects