user@techdebt:~/blog$_
$ cd ..

Two Threads, One Counter

Thread 1 IDLE LOAD ADD STORE RELEASE counter 0 GIL: -- OPEN Queue [ ] Consumer total = 0 Thread 2 IDLE LOAD ADD STORE RELEASE
mode:
$ thread inspector
click Step or Play to inspect thread state
$ simulation.log

the bug

Here’s code that looks correct and isn’t:

import threading

counter = 0

def increment():
    global counter
    for _ in range(100_000):
        counter += 1

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

print(counter)  # expected: 200000

Run this a few times. You’ll get numbers like 134,291 or 167,440. Never 200,000. The counter is losing increments.

why it breaks

counter += 1 looks like one operation. It isn’t. Python compiles it to four bytecode instructions:

LOAD_GLOBAL    counter    # read counter into local
LOAD_CONST     1
BINARY_ADD                # compute counter + 1
STORE_GLOBAL   counter    # write result back

The GIL (Global Interpreter Lock) protects Python’s internal state, but it can release between any two bytecodes. If Thread 1 does LOAD (reads 0), then the GIL switches to Thread 2, which also reads 0, both threads compute 0 + 1 = 1, and both write 1 back. Two increments happened, but the counter only went up by one. That’s a lost update.

The simulation above shows exactly this interleaving. Step through “Unsafe” mode and watch both threads read the same stale value.

the fix

Wrap the critical section in a threading.Lock():

lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:
            counter += 1

with lock acquires before entering and releases on exit. If Thread 2 tries to acquire while Thread 1 holds it, Thread 2 blocks until Thread 1 releases. No two threads can be inside the critical section at the same time. Switch the simulation to “Lock” mode to see this in action.

the better fix

Don’t share mutable state. If neither thread writes to a shared variable, there’s nothing to race on.

from queue import Queue

q = Queue()

def producer():
    for _ in range(100_000):
        q.put(1)

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

total = 0
while not q.empty():
    total += q.get()

print(total)  # always 200000

queue.Queue is thread-safe internally. Each thread only writes to the queue, and a single consumer reads from it. No shared mutable state, no race condition. Switch to “Queue” mode in the simulation to see the difference.