What the GIL actually protects (and what it doesn't)
The Global Interpreter Lock means only one thread executes Python bytecode at a time -- but that's a
guarantee about bytecode instructions, not about your source-level statements. A single bytecode
instruction (like list.append) is effectively atomic: no other thread can observe it half-done. A
compound operation like x += 1 compiles to multiple bytecode instructions -- and the GIL can, in
principle, switch threads between any of them.
import dis
def increment():
global counter
counter += 1
dis.dis(increment)
print()
print("four separate instructions: LOAD, LOAD, BINARY_OP, STORE -- the GIL can switch threads between any of them")
A single, indivisible bytecode operation like list.append really does stay safe under concurrent
threads -- no corruption, even from 8 threads hammering it at once:
import threading
shared_list = []
def appender(n):
for i in range(n):
shared_list.append(1)
threads = [threading.Thread(target=appender, args=(20000,)) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()
print("append() from 8 threads, expected 160000, got:", len(shared_list), "-- no corruption")
Now the honest part: counter += 1 is a compound operation and is not guaranteed atomic -- this is
real, well-documented CPython behavior. But whether a given run actually exhibits the resulting lost
updates depends on unpredictable thread-scheduling timing. Here's what actually happened running this on
this machine, with aggressive tuning specifically trying to provoke it (a very short GIL switch interval, 16
threads, a synchronization barrier so they all start at the exact same instant):
import sys
sys.setswitchinterval(0.000001) # switch as aggressively as possible, trying to expose the race
counter = 0
n_threads = 16
barrier = threading.Barrier(n_threads)
def incrementer(n):
global counter
barrier.wait() # every thread starts the loop at the same instant -- maximum contention
for i in range(n):
counter += 1
threads = [threading.Thread(target=incrementer, args=(300000,)) for _ in range(n_threads)]
for t in threads: t.start()
for t in threads: t.join()
expected = n_threads * 300000
print(f"expected {expected}, got {counter}", "<- LOST UPDATES" if counter != expected else "(no corruption observed this run)")
No corruption showed up in this run -- and that's the real, honest result, not a cleaned-up story.
It does NOT mean += is actually safe: the bytecode evidence above is unconditional proof it's not atomic,
regardless of whether any particular run happens to expose it. This is itself an important, real lesson
about concurrency bugs: the absence of an observed failure in testing is not proof of safety. A race that's
"real but hard to trigger" is exactly the kind that survives code review, passes CI, and then shows up once
in production under different scheduling, load, or hardware -- which is precisely why the rule is "never
trust a compound operation without a lock," not "trust it until it visibly breaks."