Two beliefs about the GIL are common, and both of them cost money.
The first is “the GIL makes Python thread-safe.” Teams holding it ship a shared
counter with no lock, a dedup set mutated from four request handlers, an LRU
cache whose eviction is a read-modify-write. Under light load it works. Under
load it produces a wrong number roughly once a week, and the bug is never
reproducible in staging.
The second is “the GIL means threads are useless.” Teams holding it reach for
multiprocessing on a workload that spends 95% of its time waiting on a
socket, and pay a fork per worker, a pickle per message and 10x the resident
memory for a speedup of zero.
Both come from not knowing what the guarantee actually is. It is small, it is precise, and it is worth being able to state in one sentence.
The guarantee
The CPython interpreter switches threads between bytecode instructions, not in the middle of one. So each individual bytecode executes atomically from the point of view of Python code, and so does each call into C that does not itself release the lock.
That is the whole thing. Everything people believe about the GIL is a consequence of that sentence, or is false.
What follows immediately:
-
A single bytecode is indivisible.
LIST_APPEND,STORE_SUBSCRon adict, alist.appendthat bottoms out in one C call — no other thread observes a half-finished state. -
A statement is not.
self.n += 1compiles to a load, an add and a store. Three instructions, two switch points, and the classic lost update. - A method call you wrote is not. Anything with a Python frame in it has as many switch points as it has bytecodes.
What the interpreter does not promise
The GIL is not a scheduler. From the sys documentation: the interpreter does
not have its own thread scheduler; which thread runs next is the operating
system’s decision. sys.setswitchinterval() sets how long the running thread
holds the GIL before being asked to drop it — the default is 5 milliseconds —
but “asked to drop it” is not “the thread you want runs next”. You cannot
reason about ordering, fairness, or starvation from Python.
Two more things the GIL does not do:
-
It is not held across blocking I/O. A thread in
socket.recv,file.read,time.sleeporsubprocess.waithas released it. This is why threads are a perfectly good answer for I/O-bound work. -
It is not held by every C extension. Extensions that do long computations are
expected to release it around them, and the ones that matter do:
NumPy’s heavy kernels,
zlib,hashlib, Pillow’s codecs, compiled ML kernels. CPU-bound code inside such a library is already parallel across threads.
💡A service handler does results = [r for r in db.query(sql)] and, in parallel, another handler does the same. Both are CPU-light and I/O-heavy. Someone proposes moving to multiprocessing "because of the GIL". What is wrong with the proposal, and what measurement settles it?
click to reveal
Nothing about that workload is GIL-bound. The time is spent inside the database driver’s socket read, and the GIL is released for the whole of it — so N threads genuinely wait on N sockets simultaneously. Processes would buy you nothing except a fork, a pickle round trip per row batch, and N copies of the interpreter and of every imported module.
The measurement that settles it is the ratio of CPU time to wall time for the
process: time python app.py and compare user + sys against real. If user
time is a small fraction of wall time, you are waiting, not computing, and the
GIL is not your constraint. py-spy dump on a live process answers the same
question qualitatively — if every thread’s top frame is in a recv, there is
no contention to remove.
The genuinely GIL-bound case is: CPU time approaches wall time, the hot frames are pure Python, and adding threads does not reduce wall time. That is when the answer is processes, subinterpreters, or a free-threaded build.
Why the atomic list is a trap
The FAQ publishes a list of operations that happen to be atomic in CPython
today — L.append(x), D[x] = y, L1.extend(L2) — and an explicit list of
operations that are not — i = i + 1, L.append(L[-1]), D[x] = D[x] + 1.
Do not memorise it. Three reasons.
It describes an implementation, not the language. It is true of CPython’s current compiler and object layout. Nothing in the language reference promises it, and the free-threaded build declines to guarantee it.
It is fragile under edits that look innocent. D[x] = y is atomic when x
and y are already-evaluated locals. Make x a call and you have introduced
switch points into the same line. Give the container a __setitem__ written in
Python — a defaultdict with a Python __missing__, a custom mapping, a
Counter subclass — and there is a whole Python frame inside the “atomic”
operation.
Atomicity of a single operation is almost never the invariant you need. The thing you actually want is “no two threads both observe capacity as available”, which spans a read and a write no matter how atomic either is individually.
The FAQ’s own conclusion is the one to keep: when in doubt, use a mutex. A
threading.Lock acquire/release is tens of nanoseconds uncontended. It is
never the reason your service is slow, and it is the only construct whose
correctness does not depend on a CPython implementation detail.
💡counts[key] += 1 where counts is a collections.Counter shared by four threads. Is it safe? What about counts.update([key])?
click to reveal
Neither is safe, and for different reasons worth separating.
counts[key] += 1 is the FAQ’s own non-atomic example. It is a __getitem__,
an add, and a __setitem__ — and on a Counter the __getitem__ path for a
missing key runs __missing__, which is Python code. Two threads can both read
7 and both write 8.
counts.update([key]) looks better because it is one method call, and on a
plain dict update from another dict is atomic. But Counter.update is
implemented in Python — it loops, reading and writing each key — so it has
frames, and therefore switch points, all the way through. The single-call shape
tells you nothing.
The fix is boring and correct: a Lock around the mutation, or — better where
it fits — give each thread its own Counter and merge them once at the end
with +. No shared mutable state means no lock and no contention, which is
also considerably faster than a hot lock.
What this buys you in practice
Three rules follow, and they cover most real code.
- Shared mutable state gets a lock, always. Not “if the operation looks atomic”. The cost of the lock is not the thing that will show up in your flame graph.
-
Prefer not sharing. Per-thread accumulators merged at the end, or a
queue.Queue(which owns its own locking) as the only channel between threads, removes the question rather than answering it. - Diagnose before you change the model. CPU-time-over-wall-time tells you whether the GIL is even in the picture. If it is not, threads are the right tool and you have a different problem.
Track 10 takes up what happens when the GIL genuinely is the constraint — processes, subinterpreters and free-threaded builds. This track is about being correct first, because a concurrency bug that only appears under load is the most expensive kind of bug there is.