๐Ÿ”’ Pattern 2: Locks & Race Conditions

The moment two threads touch the same variable, you need a lock. Here's why, and how to do it right.

The Problem: The Lost Update

Scenario: two threads each add 1 to a shared counter one million times. You expect 2,000,000. You get something less, and it changes every run.
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

Why counter += 1 Is a Trap

That single line is really three operations, and a thread can be paused between any of them:

sequenceDiagram participant A as Thread A participant M as counter (=41) participant B as Thread B A->>M: read 41 Note over A,B: OS pauses A right here B->>M: read 41 B->>M: write 42 A->>M: write 42 Note over M: Two increments happened,
but counter only went 41 โ†’ 42.
One update LOST.

The Solution: A Lock

Pattern: wrap the shared-state update in 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
Always use 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.

The Critical Section: Keep It Small

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
        ...
Rule: use 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).

What's Already Thread-Safe?

Not every shared access needs a lock. Some operations are atomic thanks to the GIL, and some types are built for concurrency.

Don't rely on GIL atomicity for logic. "Which bytecodes are atomic" is a CPython implementation detail. If two threads read and write shared state, lock it โ€” it's cheap and it's correct.

Key Takeaways

ConceptTakeaway
Race conditionTwo threads read-modify-write the same state โ†’ lost updates
LockMakes a critical section one-thread-at-a-time
with lock:Always โ€” auto-releases on exceptions
Critical sectionKeep it tiny; never hold a lock across I/O
RLockOnly when a thread must re-acquire its own lock
Careful: more locks = more chances for two of them to wait on each other forever. That's a deadlock โ€” see Pattern 6.