Mastery
Mastery/Python/F. Concurrency & parallelism
T1 · high-leverage

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.

python
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:

python
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):

python
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."

Interview angle

A genuinely great filter question: "is counter += 1 thread-safe in Python, since there's a GIL?" Weak candidates say yes ("only one thread runs at a time"). Strong candidates say no, and can explain why in terms of bytecode — the GIL guarantees one instruction runs at a time, not one statement, and += is several instructions. The best candidates also know that demonstrating the resulting bug reliably is itself nondeterministic, which is a mark of real production experience, not textbook knowledge.

In the industry

This exact confusion — "the GIL means my Python code doesn't need locks" — is a real, recurring source of subtle production bugs in codebases that use threads for shared mutable state. The standard fix isn't avoiding threads; it's using threading.Lock, queue.Queue, or Atomic-style patterns for anything beyond single, indivisible operations. It's also the direct motivation for why so much concurrent Python code prefers message-passing (queues) over shared mutable counters/state in the first place — sidestepping the question of atomicity entirely rather than reasoning about it case by case.