skip to content

Why does `totals[key] = totals[key] + 1` race across threads when each dict operation is indivisible?

level: middleimportance: must knowfreq 65%

answer

  1. Count the operations in the line
  2. Read, add, store are three steps
  3. Both threads see the same old value
  4. The GIL narrowed the window, never closed it
  5. Private counters merged once at the end

basics

~20 s

That line is two dict operations, not one: a read, then a write, with a gap between them. Two threads can both read the same old value and both store it plus one, so an increment is lost. Lock both.

solid answer

~40 s

The atomicity guarantee on built-in containers is exactly one operation wide. `totals[key] = totals[key] + 1` compiles to a subscript load, an add, and a subscript store — the interpreter can schedule another thread in between. Two threads both read `7`, both compute `8`, both store `8`, and one increment vanishes; nothing was corrupted, the *invariant* spanned two operations and nobody protected it. Under the GIL this was already true, just rarer, because a switch only happened at bytecode boundaries every few milliseconds; on a free-threaded build the two threads genuinely run at once and the window is wide open. The fix is a `threading.Lock` held across the read and the write, or better, restructuring so each worker counts into its own object and one thread merges the results at the end.

code

python · 17 lines
python
import threading

totals = {}
lock = threading.Lock()

def bump(key, n):
    for _ in range(n):
        with lock:                                # covers read AND write
            totals[key] = totals.get(key, 0) + 1

threads = [threading.Thread(target=bump, args=("a", 5000)) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(totals["a"])  # 20000, every run

go deeper

for a junior

Be able to say that the line is a read and a write, and that two threads can read the same value before either writes. Knowing the threading.Lock fix by name is enough at this level.

for a middle

Explain the mechanics: three bytecode steps, a scheduling gap between them, the lost update, and why the GIL only narrowed the window. Show the lock covering both the read and the write.

for a senior

Demonstrate judgement about granularity — that a lock per item can be slower than one thread, that private accumulation plus a single merge is the shape that scales, and that locks never wrap slow I/O.

for a principal

Own the standard: whether shared mutable counters are allowed in your codebase at all, what aggregation pattern teams default to, and how you review threaded code so lost updates are caught before a long batch job reports quietly wrong numbers.

### Count the operations, not the lines The single most useful habit for this whole subject is to stop reading Python statements and start counting the *container operations* inside them. `totals[key] = totals[key] + 1` is one line and three steps: subscript the dict to get the current value, add one to it, store the result back into the dict. Two of those three touch the shared object, and they are separated by an arbitrary amount of time — the interpreter may schedule another thread between any two bytecodes, and on a free-threaded build another thread may simply be executing the same line simultaneously on another core. The failure is the classic lost update. Both threads read 7. Both compute 8. Both store 8. Two increments went in, one came out. Nothing about the dict is damaged: every individual read returned a coherent value and every individual write landed intact. The broken thing is an invariant of *your program* — "the stored value reflects every increment" — and no container can enforce an invariant it does not know about. ### Why the GIL only ever hid this It is tempting to remember `+=` as "safe under the GIL" because a toy test with two threads and a hundred iterations always printed the right answer. The GIL never made a compound update atomic; it only made the losing interleaving rare, because a thread released the lock at a bytecode boundary roughly every `sys.setswitchinterval` seconds (5 ms by default) rather than being genuinely concurrent. Push the loop to a million iterations under the GIL and the wrong answer shows up reliably. On a free-threaded build (experimental in 3.13, officially supported in 3.14 by PEP 779) the two threads run at the same instant on different cores, so what used to be an occasional off-by-a-few becomes routine. The migration does not introduce the bug; it stops concealing it. ### The direct fix, and its cost Wrap the whole read-modify-write in one `threading.Lock`, acquired with a `with` statement so it is released on every path including an exception. The invariant is now protected because no other thread can observe or modify the counter between your read and your write. The cost is real: if the increment is the hot inner loop, every worker now queues on one lock and you have re-created, in your own code, the very serialization the free-threaded build was adopted to escape. Two threads on a contended lock can be slower than one thread with no lock. ### The better fix: don't share the counter The shape that scales is privatization plus a single merge. Each worker accumulates into an object only it can see — a local `dict`, a local `collections.Counter`, a value in a `threading.local` — and when the work is done, one thread folds the partial results together. The shared mutation happens once per worker instead of once per item, so the lock (or the sequential merge) is off the hot path entirely. The same idea in a different wrapper is to hand results to a single consumer through a `queue.Queue` and let that one thread own the aggregate. ### Two traps worth naming out loud First, swapping the plain dict for a `collections.Counter` fixes nothing: `c[k] += 1` on a shared Counter is the same read-modify-write with a nicer default. The Counter in the example below is safe because each thread owns its own, not because Counter is special. Second, tuning `sys.setswitchinterval` is not a fix — it changes how often you lose, not whether you can. Any answer that reaches for scheduler tuning instead of a lock or a restructure is a red flag in an interview and a latent incident in production. ### The rule to carry away Built-in containers promise that each operation is internally consistent. They promise nothing about a sequence of operations, and almost every interesting update is a sequence: read-then-write, test-then-insert, check-then-act. Decide what your invariant is, notice how many container operations it spans, and if the answer is more than one, it needs a `threading.Lock` around all of them — or it needs to stop being shared.

  • Does replacing the dict with a `collections.Counter` remove the race?
    No. `counter[k] += 1` on a shared Counter is still a read, an add and a write, so two threads can still lose an update. Counter only supplies a zero default. It becomes safe when each worker owns a private Counter and one thread merges them afterwards.
  • Where should the lock go if the loop also does slow I/O per item?
    Around the shared update only, never around the I/O. Holding a lock across a slow call serializes the workers on the slowest thing in the loop. Do the I/O unlocked, then take the lock for the few microseconds the counter update needs — or accumulate privately and merge once.
  • Would raising `sys.setswitchinterval` make the unlocked version correct?
    No. It only lengthens the slice a thread runs before the interpreter considers a switch, which makes the losing interleaving rarer on a GIL build and does nothing at all on a free-threaded build where threads run simultaneously. It converts a reproducible bug into an intermittent one.

Two clerks each take the ledger's current total on a sticky note, add their own sale, and write their sticky note back. Both notes said 7; the ledger ends at 8 no matter how carefully each of them writes.

saying these in an interview costs you the question

  • Says += on a shared counter is atomic in CPython
  • Believes the GIL made compound updates safe
  • Swaps in collections.Counter and calls it fixed
  • Locks only the write and not the read
  • Tunes sys.setswitchinterval instead of locking
  • Holds the lock across slow I/O inside the loop

context