The Real Secret to Thread-Safe Code

We’ve all been taught concurrency the wrong way around. Concurrency tutorials start with threads, locks, deadlocks, and execution ordering — they frame threading as a scheduling problem to solve.

The actual problem is much simpler: thread safety breaks when two things are true at once, shared by multiple threads, and mutable enough that one thread’s read can conflict with another’s write. Fix either condition and the concurrency problem disappears entirely.

This post walks through three versions of the same bug — a counter being corrupted across four threads — solved three different ways.

The problem: shared mutable state without coordination

Four threads each increment a shared counter 10,000 times. The expected result is 40,000. Without any coordination, every update gets lost:

counter = {"value": 0}

def increment():
    for i in range(ITER):
        old = counter["value"]      # read
        if i % STEP == 0:
            time.sleep(0)              # release GIL — window for races
        counter["value"] = old + 1   # write

The race isn’t subtle. Each thread reads the same stale value, computes its own result on top of that stale read, and writes back. All four threads might read 42, all compute 43, and the counter ends up at 43 instead of 46.

Here’s the code we’ll run:

Running it

============================================================
Example 1: Shared mutable counter — NO coordination
============================================================
Results over 8 runs: min=10000, max=13000
Expected value: 40,000
Got the right answer: 0/8 times
LOST UPDATES: 27,000 of 40,000 updates disappeared.

============================================================
Example 2: Shared mutable counter — managed access with one lock
============================================================
Results over 8 runs: min=40000, max=40000
Expected value: 40,000
Got the right answer: 8/8 times

============================================================
Example 3: Immutable local state — merge afterward
============================================================
Results over 8 runs: min=40000, max=40000
Expected value: 40,000
Got the right answer: 8/8 times

============================================================
Timing comparison (single run each):
============================================================
  Shared mutable + lock          → 0.009s
  Immutable local state          → 0.002s

Example 1 confirms the race: across eight runs, we got values between 10,000 and 13,000 — at most one-third of what should have been produced. Twenty-seven thousand updates were swallowed by overlapping reads.

Example 2 shows that a single lock around the shared variable fixes it every time. The key insight is not about the lock itself but about what it protects: one piece of shared mutable data, accessed through exactly one critical section in each thread. That’s it — the entire concurrency model reduces to “protect the shared state with one entry point per operation.”

Example 3 eliminates shared mutable data entirely. Each thread counts locally (zero sharing), then appends its result once at the end. There’s no lock needed for the append because each thread writes exactly once and Python’s GIL makes that single write atomic by coincidence. The concurrency model disappears — there’s nothing to synchronize because there’s nothing to share.

Takeaway

Thread-safe code isn’t about controlling which thread runs when or how many locks you need. It’s about asking a simple question: “what shared, mutable data do these threads touch?” Answer that honestly and manage access to it — or better yet, remove the sharing altogether — and concurrency becomes straightforward.