Skip to content

Threads, Locks, and Race Conditions

Key concepts:

  • Threads share memory within a process (unlike processes which are isolated)
  • Shared state + multiple threads = race conditions
  • Locks (threading.Lock) control access to shared state
  • with lock: is a context manager that auto acquires/releases
  • Minimize the critical section (locked code) -- keep it fast

Run this code locally or try snippets in the Playground.

Race Condition

counter += 1 is NOT atomic. It's actually: read → add → write. Two threads can read the same value, both add 1, both write back = lost update.

sequenceDiagram
    participant T1 as Thread 1
    participant C as counter (shared)
    participant T2 as Thread 2

    Note over C: counter = 0
    T1->>C: READ counter (gets 0)
    T2->>C: READ counter (gets 0)
    T1->>C: WRITE counter = 0 + 1
    Note over C: counter = 1
    T2->>C: WRITE counter = 0 + 1
    Note over C: counter = 1 (lost update!)
import threading

counter = 0

def increment_unsafe():
    global counter
    for _ in range(1_000_000):
        counter += 1  # NOT atomic: read, add, write can be interleaved

t1 = threading.Thread(target=increment_unsafe)
t2 = threading.Thread(target=increment_unsafe)
t1.start()
t2.start()
t1.join()
t2.join()

print(f"Unsafe counter (expect 2,000,000): {counter}")  # will be less!

Fix 1: Use a Lock

stateDiagram-v2
    [*] --> Unlocked
    Unlocked --> Acquired: Thread calls acquire()
    Acquired --> Unlocked: Thread calls release()
    Unlocked --> Acquired: Another thread acquires
    Acquired --> Blocked: Other thread tries acquire()
    Blocked --> Acquired: Lock released, blocked thread wakes up

Only one thread can hold the lock at a time. with lock: is a context manager -- it acquires on entry and releases on exit (even if an exception is thrown).

counter = 0
lock = threading.Lock()

def increment_safe():
    global counter
    for _ in range(1_000_000):
        with lock:         # acquire lock (context manager, auto-releases)
            counter += 1   # only one thread at a time

t1 = threading.Thread(target=increment_safe)
t2 = threading.Thread(target=increment_safe)
t1.start()
t2.start()
t1.join()
t2.join()

print(f"Safe counter (expect 2,000,000): {counter}")  # exactly 2,000,000
sequenceDiagram
    participant T1 as Thread 1
    participant L as Lock
    participant C as counter (shared)
    participant T2 as Thread 2

    T1->>L: acquire()
    activate L
    Note over L: LOCKED
    T2->>L: acquire()
    Note over T2: BLOCKED (waiting)
    rect rgb(50, 50, 80)
        Note over T1,C: Critical Section
        T1->>C: READ counter
        T1->>C: WRITE counter + 1
    end
    T1->>L: release()
    deactivate L
    Note over L: UNLOCKED
    L->>T2: acquired!
    activate L
    rect rgb(50, 50, 80)
        Note over T2,C: Critical Section
        T2->>C: READ counter
        T2->>C: WRITE counter + 1
    end
    T2->>L: release()
    deactivate L

Downside: if ALL the work is inside the lock, you've made it sequential again.

Fix 2: Avoid Shared State

Each thread works on its own counter, combine at the end. Often simpler and less bug-prone than locking.

def increment_isolated(results, index):
    local_counter = 0
    for _ in range(1_000_000):
        local_counter += 1
    results[index] = local_counter

results = [0, 0]
t1 = threading.Thread(target=increment_isolated, args=(results, 0))
t2 = threading.Thread(target=increment_isolated, args=(results, 1))
t1.start()
t2.start()
t1.join()
t2.join()

print(f"Isolated counter (expect 2,000,000): {sum(results)}")