The moment two threads touch the same variable, you need a lock. Here's why, and how to do it right.
import threading
counter = 0
def work():
global counter
for _ in range(1_000_000):
counter += 1
ts = [threading.Thread(target=work) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print(counter) # e.g. 1_374_215 โ NOT 2_000_000
counter += 1 Is a TrapThat single line is really three operations, and a thread can be paused between any of them:
with lock:. Only one thread can
hold the lock at a time; the rest wait their turn. The read-add-write becomes indivisible (atomic).
import threading
counter = 0
lock = threading.Lock()
def work():
global counter
for _ in range(1_000_000):
with lock: # acquire โ run body โ release (even on error)
counter += 1
ts = [threading.Thread(target=work) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print(counter) # 2_000_000, every time
with lock: rather than manual
lock.acquire()/lock.release(). The context manager releases the lock even if
the body raises โ a forgotten release() after an exception is a classic hang.
A lock serializes everything inside it โ while one thread holds it, all others block. Hold it only for the actual shared-state mutation, never around slow I/O.
# โ Bad: network call happens while holding the lock โ kills concurrency
with lock:
data = requests.get(url).json() # everyone waits on YOUR network call
cache[key] = data
# โ
Good: do the slow work outside, lock only the shared write
data = requests.get(url).json()
with lock:
cache[key] = data
Lock vs RLock
A plain Lock is not re-entrant: if the same thread tries to acquire it
twice (e.g. a locked method calls another locked method), it deadlocks on itself. An
RLock (re-entrant lock) can be acquired multiple times by the thread that owns it.
lock = threading.Lock()
with lock:
with lock: # โ hangs forever โ Lock can't be re-acquired by its owner
...
rlock = threading.RLock()
with rlock:
with rlock: # โ
fine โ same thread, re-entrant
...
Lock by default. Reach for RLock only when a
thread genuinely needs to re-acquire a lock it already holds (recursive or layered locked calls).
Not every shared access needs a lock. Some operations are atomic thanks to the GIL, and some types are built for concurrency.
queue.Queue โ fully thread-safe; the preferred way to pass data. (Pattern 3)list.append() or dict[key] = v โ atomic in CPython. But x += 1, or read-then-write sequences, are not.| Concept | Takeaway |
|---|---|
| Race condition | Two threads read-modify-write the same state โ lost updates |
Lock | Makes a critical section one-thread-at-a-time |
with lock: | Always โ auto-releases on exceptions |
| Critical section | Keep it tiny; never hold a lock across I/O |
RLock | Only when a thread must re-acquire its own lock |