Any Shared State Needs a Gatekeeper

Any Shared State Needs a Gatekeeper

When multiple threads read or write the same variable, that variable needs a synchronization primitive — a lock, an atomic type, a volatile field, or a thread-safe collection. Without it, updates are silently lost and readers see torn values.

Three short examples walk through why this is unavoidable, how different primitives fix it, and when you can skip manual locking entirely by choosing the right abstraction.

The bare counter

Four threads each add 5000 to a shared integer. Between the read and the write back sits a tiny sleep that widens the interleaving window — any gap like this is where races live:

def writer(id_, iterations=5000):
    for _ in range(iterations):
        snapshot = state.counter       # READ
        time.sleep(1e-7)               # gap: other threads can read the same value here
        state.counter = snapshot + 1   # WRITE — may overwrite a peer's update

Each thread reads the current value, increments it in its own register, then writes back. But two threads reading the same snapshot both write snapshot + 1 — one of those updates vanishes.

The counter landed at a small fraction of the expected total. Every time two threads read the same value and both write it back incremented, the later write silently overwrites the earlier one. The read-modify-write sequence is not atomic, so concurrent reads produce identical snapshots and duplicate increments.

Guarding with a Lock

The fix is mutual exclusion: wrap the entire read-modify-write in a lock so no two threads can be inside at once:

def writer(id_, iterations=5000):
    for _ in range(iterations):
        with state.lock:               # only one thread enters this block at a time
            snapshot = state.counter     # READ — safe, nobody else is reading
            time.sleep(1e-7)             # gap doesn't matter now; others are blocked
            state.counter = snapshot + 1 # WRITE — safe, nobody else can read in between

The same sleep that destroyed the counter in example 1 is harmless here — every thread inside the lock gets exclusive access. The output lands on exactly the expected total because no two threads ever see the same snapshot.

Skip the Lock by Choosing a Thread-Safe Abstraction

Manual locking works, but it is error-prone: if any code path forgets to acquire the lock, the race reappears. A second approach is to use a data structure that coordinates access internally — queue.Queue in Python, for example:

def demo_with_queue():
    q = queue.Queue()

    def producer(n):
        for i in range(n):
            q.put(f"item-{i}")       # thread-safe; no lock needed
        q.put(sentinel)

    def consumer():
        while True:
            item = q.get()             # blocks until an item is available
            if item is sentinel:
                break
            results.append(item)

queue.Queue handles all the locking internally. The producer calls put() and the consumer calls get() — each call is a coordination point, and every path through the code goes through those same gates. Both approaches — manual Lock or thread-safe collection — deliver all items safely. The difference is that the queue makes the synchronization obvious at the API level; you cannot “forget” to lock because there are no locks to forget.

Takeaway

Any variable written by one thread must be coordinated through a shared primitive if any other thread reads it. Locks provide explicit gates around read-modify-write sequences; thread-safe collections bake those gates into the API so there is no way to bypass them.