24 — The GIL: from the cache line to the eval loop¶
Tier 4, doc 24. Prerequisites:
01-memory-hierarchy-and-caches.md(cache lines, MESI, false sharing),02-atomics-and-memory-models.md(CAS, barriers, x86-TSO),06-processes-threads-scheduling.md(context switches, futexes),15-refcounting-and-ownership.md(borrowed vs owned refs).This is the vertical-slice document of the whole folder. It starts at the intercore bus and ends at
sys.setswitchinterval, because the GIL is not a design choice anyone would make today — it is the shadow cast by reference counting onto a cache- coherent multiprocessor. Understand that sentence physically and every other GIL question answers itself.Version baseline: Python 3.14 (latest stable). Where behaviour changed across versions the version is named inline. Field names and code shapes in
ceval_gil.cchurn between releases — read this for the mechanism, then confirm against the source tree you actually ship.
Contents¶
- The physical layer: why refcounting hates multicore
- What the GIL actually is
- A short history: the old GIL, the GIL battle, and Pitrou's rewrite
- The eval loop: where the GIL is actually dropped
- What releases the GIL — and what doesn't
- OS interaction: mutexes, condvars, futexes, and the scheduler
- The convoy effect, measured
- Signals, Ctrl-C, and fork
- What the GIL does and does not guarantee
- Sub-interpreters: one GIL each (PEP 684 / PEP 734)
- The Gilectomy: Larry Hastings' seven-core lesson
- What Sam Gross did differently (PEP 703)
- Free-threading's new cost model
- C extensions under free-threading
- Choosing a concurrency model
- Diagnosing GIL problems in production
- The GIL elsewhere: other implementations
- Lab exercises
- Question bank
- Sources
1. The physical layer: why refcounting hates multicore¶
Start below Python. Below C. At the coherence protocol.
Every PyObject begins with a reference count. Every time Python touches an object —
loading a name, passing an argument, returning a value, iterating — that counter is
incremented and later decremented. In a hot loop this is the most frequently written
memory location in the process.
Now put two cores on it.
Core 0 Core 1
┌──────────┐ ┌──────────┐
│ L1D │ │ L1D │
│ [None's │ │ [None's │
│ refcnt] │ ◀──── cache line, 64 bytes ──▶│ refcnt] │
└────┬─────┘ └─────┬────┘
│ │
└──────────────┬───────────────────────────┘
▼
┌──────────────────────────┐
│ coherence fabric (MESI) │ ← every write here is a
│ L3 / interconnect │ cross-core transaction
└──────────────────────────┘
Under MESI, a cache line can be Modified, Exclusive, Shared, or Invalid. To write a line, a core must hold it in M or E state — which means invalidating every other core's copy. So:
- Core 0 does
Py_INCREF(None)→ acquires the line exclusively, invalidates Core 1. - Core 1 does
Py_INCREF(None)→ request-for-ownership, stalls until the line migrates across the interconnect, invalidates Core 0. - Repeat, millions of times per second.
This is cache line ping-ponging. An L1 hit is ~4 cycles. A line bounced from another core's L1 is ~40–100+ cycles, and across sockets on a NUMA machine, worse. The counter is small; the coherence traffic is not.
Three separate costs stack here, and staff-level answers distinguish them:
| Cost | What it is | Roughly |
|---|---|---|
| Atomicity | lock xadd vs plain add — pipeline serialization, store-buffer drain |
~20–50 cycles even uncontended |
| Coherence | The line migrating between cores | ~40–300 cycles depending on topology |
| Contention | N cores serializing on one line — throughput scales as 1/N or worse | unbounded |
And crucially: this happens even when the objects are logically read-only. Two
threads merely reading None, True, small integers, a shared module dict, or a class
object still write to those refcounts. A pure-read workload generates pure-write
coherence traffic. That is the central perversity of reference counting on multicore, and
it is why "just make refcounts atomic" is not a solution — it is the thing that killed
the first serious attempt (§11).
Connect it downward: this is the same phenomenon as false sharing (
01-memory-hierarchy-and-caches.md), except it isn't false. It's true sharing — the threads genuinely contend for the same word. Padding cannot save you. Only not writing can.
The same fact bites you in a completely different place. os.fork() gives you
copy-on-write pages, and everyone expects the child to share the parent's memory for
free. It doesn't, because touching an object writes its refcount, which dirties the page,
which copies it. A "read-only" traversal of a large preloaded data structure in a forked
worker will steadily copy the entire structure. Refcount write-amplification defeats CoW
for exactly the reason it defeats multicore scaling: there is no such thing as reading
a Python object. (Covered in depth in 27-multiprocessing-and-subinterpreters.md;
immortalization, §12.1, is the mitigation in both cases.)
2. What the GIL actually is¶
Given the above, the original CPython answer (1992, single-core era) is the cheapest
possible one: allow only one thread to execute bytecode at a time, and then refcount
updates need no atomics at all. Plain non-atomic add instructions. No coherence
traffic. No memory barriers. Single-threaded performance is optimal, and the entire
C-API becomes trivially thread-safe for free.
The GIL is therefore not a lock protecting a data structure. It is a license to be non-atomic everywhere else.
Concretely, in CPython it is a small struct (see Python/ceval_gil.c, historically
ceval_gil.h) holding roughly:
| Field | Purpose |
|---|---|
locked |
atomic flag: is the GIL held? |
switch_number |
monotonically increasing counter, bumped on every handoff — used to detect "did a switch actually happen?" |
last_holder |
the PyThreadState* that most recently held it, so a waking thread can tell whether it was handed off to someone else |
mutex |
pthread mutex guarding the above |
cond |
pthread condition variable that waiters block on |
interval |
the switch interval (default 5 ms) |
Plus, per-interpreter, the request flag:
| Field | Purpose |
|---|---|
gil_drop_request |
atomic flag: "somebody wants the GIL, please yield" |
eval_breaker |
the combined signal the eval loop checks |
Scope note. Since Python 3.12 (PEP 684) "the GIL" is not necessarily process-global:
each interpreter can own one. The struct above lives in _PyRuntimeState for the main
interpreter and in PyInterpreterState for interpreters created with their own GIL. In
the common single-interpreter process the distinction is invisible; §10 is where it
matters.
3. A short history: the old GIL, the GIL battle, and Pitrou's rewrite¶
The design in §2 is the new GIL — Antoine Pitrou's rewrite, landed in Python 3.2
(2011). You need the old one to understand why the new one looks like that, and why
switch_number exists at all.
The old GIL: a bytecode counter¶
Before 3.2, the holder decremented a tick counter and dropped the GIL every N
bytecode instructions. N defaulted to 100 and was tunable with sys.setcheckinterval()
(deprecated in 3.2, removed in 3.9). Drop, signal the condvar, immediately try to
reacquire.
The failure is obvious once you look at it from the OS's point of view:
Thread A: drop_gil() → cond_signal → take_gil() ← A is already running,
│ so it gets there first
▼
Thread B: ...wakes up in the kernel, is placed on the run queue,
eventually scheduled, checks `locked` → still taken → sleeps again
Signalling a condvar does not transfer the CPU. Thread A is already on a core; Thread B has to be woken, queued, and scheduled — microseconds later. A reacquires uncontested. B burns a full wake/sleep cycle for nothing, hundreds of thousands of times a second.
Dave Beazley's 2009–2010 talks made this concrete and famous. On a dual-core machine, a CPU-bound workload of roughly 24.6 s sequentially took ~45 s split across two threads on one core — and got worse, not better, when a second core was available (~68 s in his measurements). Two cores made threaded Python nearly 3× slower than one thread. The reason: with two cores both threads genuinely run at once, so both are constantly fighting over the GIL instead of one merely waiting. This is the GIL battle (or "GIL thrashing"): a convoy of futile wakeups burning system time.
Two second-order problems compounded it:
- The counter measured the wrong thing. 100 bytecodes is not a unit of time. 100 ×
LOAD_FASTis nanoseconds; 100 × aBINARY_OPon 10,000-digit ints is seconds. The switch rate varied by orders of magnitude with workload. - Priorities were inverted. An I/O thread that woke up had to wait for the CPU thread's tick counter, and then win a race against it.
What Pitrou changed¶
The new GIL replaces "drop every N ticks" with "yield when asked":
- A waiter blocks on the condvar with a timeout (
interval, default 5 ms) instead of spinning on a counter. - On timeout, it checks
switch_number. If the GIL changed hands while it waited, fine — the system is making progress, wait again. If it did not, the holder is monopolizing, so the waiter setsgil_drop_request. - The holder sees
gil_drop_requestat its next check point and yields.
This kills the battle: no drop happens unless someone actually wants the GIL, and the
timeout is real time rather than instruction count. switch_number exists purely to
answer "did anything happen while I was asleep?" — without it, every timeout would force
a drop even in a system that was already switching healthily.
What Pitrou did not add is fairness. There is still no handoff guarantee: the thread that forced the drop is not guaranteed to be the one that gets the GIL. That omission is the direct cause of §7.
The transferable lesson: the old GIL optimized the wrong variable (switch frequency) using the wrong unit (bytecodes) and assumed signalling implies handoff. All three assumptions are ones you can make yourself in any lock design. The fix was not a better counter — it was changing who initiates the drop.
4. The eval loop: where the GIL is actually dropped¶
Here is the mechanism, and it is more elegant — and more fragile — than most people expect. The GIL is not preemptively taken away by the OS. CPython is cooperatively scheduled at the bytecode level.
The handoff protocol¶
Thread A (holds GIL, running bytecode) Thread B (wants GIL)
──────────────────────────────────── ─────────────────────────────
take_gil():
pthread_mutex_lock(&mutex)
while (locked):
┌ pthread_cond_timedwait(
...executing bytecode... │ &cond, &mutex,
(checks eval_breaker at │ interval /* 5ms */)
designated check points — │
one predictable branch) └ TIMED OUT, and
switch_number unchanged
↓
set gil_drop_request = 1 ──┐
│
eval_breaker is now set ◀───────────────────────────────────────────────┘
↓
eval_frame_handle_pending():
if (gil_drop_request):
_PyThreadState_Swap(NULL)
drop_gil() ─────────────────────────▶ cond_signal wakes B
take_gil() ← A now queues up B sets locked, takes GIL,
behind B bumps switch_number
_PyThreadState_Swap(tstate)
The code in eval_frame_handle_pending() is essentially (this is the ~3.10 shape; see
below for what changed):
/* GIL drop request */
if (_Py_atomic_load_relaxed(&ceval2->gil_drop_request)) {
/* Give another thread a chance */
if (_PyThreadState_Swap(&runtime->gilstate, NULL) != tstate) {
Py_FatalError("tstate mix-up");
}
drop_gil(ceval, ceval2, tstate);
/* Other threads may run now */
take_gil(tstate);
if (_PyThreadState_Swap(&runtime->gilstate, tstate) != NULL) {
Py_FatalError("orphan tstate");
}
}
Where the check actually happens — and where it doesn't¶
The folklore is "between every bytecode." That was roughly true historically; it is not true now, and the difference matters.
Since 3.12–3.13 the eval breaker is a per-thread-state bitfield
(tstate->eval_breaker) packing several independent requests:
| Bit | Meaning |
|---|---|
_PY_GIL_DROP_REQUEST_BIT |
another thread wants the GIL |
_PY_SIGNALS_PENDING_BIT |
a signal arrived (§8) |
_PY_CALLS_TO_DO_BIT |
Py_AddPendingCall work queued |
_PY_ASYNC_EXCEPTION_BIT |
PyThreadState_SetAsyncExc |
_PY_GC_SCHEDULED_BIT |
cycle collection is due |
One atomic load tests all of them at once. But it is only tested at designated check points, in practice:
RESUME— emitted at function entry and after everyawait/yieldresumption.JUMP_BACKWARD— every loop back-edge.- instrumented variants of the above, plus a few explicit sites in the runtime.
Look at what the compiler actually emits:
>>> import dis
>>> def f():
... x = 0
... for i in range(3):
... x += 1
... return x
>>> dis.dis(f)
RESUME 0 ← check point (function entry)
LOAD_CONST 1 (0)
STORE_FAST 0 (x)
LOAD_GLOBAL 1 (range + NULL)
LOAD_CONST 2 (3)
CALL 1
GET_ITER
L1:
FOR_ITER 8 (to L2)
STORE_FAST 1 (i)
LOAD_FAST 0 (x)
LOAD_CONST 3 (1)
BINARY_OP 13 (+=)
STORE_FAST 0 (x)
JUMP_BACKWARD 10 (to L1) ← check point (loop back-edge)
...
Two check points in the whole function. Straight-line bytecode between them is uninterruptible by the GIL machinery. In practice every Python loop and every Python call passes through one, so a thread executing Python code always yields eventually. The consequence lives in what happens when it isn't executing Python code.
Four consequences that follow directly¶
1. The switch interval is a timeout, not a quantum. A thread does not "get 5 ms of CPU". Rather: a waiting thread waits 5 ms before it even asks. Then the holder yields at its next check point. Total latency to switch = 5 ms + time to reach a check point + scheduling latency (§6).
2. A thread that never reaches a check point never yields. This is why a single
long-running C call that holds the GIL freezes the entire interpreter — including signal
handling, including Ctrl-C. re on a pathological pattern, a huge int→str
conversion, math.factorial of something silly, a non-GIL-releasing extension: all of
them can make your process unresponsive with 15 idle cores available. There is no
preemption to save you. This is the single most common way a production Python process
goes fully dark.
3. The check is deliberately, aggressively cheap. One relaxed atomic load and a
well-predicted not-taken branch — a few cycles, mostly hidden by the out-of-order engine
(00-cpu-execution-model.md). The design pushes all cost onto the rare path, which is
also why the check points were narrowed over time: fewer sites, each cheaper, with no
loss of practical responsiveness.
4. _PyThreadState_Swap(NULL) before dropping is not bookkeeping — it is the
invariant. "Holding the GIL" and "having a current thread state" must be the same
thing. If they desync you get the Py_FatalError("tstate mix-up") you can see in the
source. This is why C extensions must use Py_BEGIN_ALLOW_THREADS /
Py_END_ALLOW_THREADS (which expand to exactly this save/restore dance) rather than
touching the GIL by hand.
Tuning it¶
Three things people get wrong about this API:
- It is a global setting for the interpreter, not per-thread. There is no way to say "this thread is latency-sensitive."
- It is a floor on how long a waiter tolerates monopolization, not a scheduling quantum. Lowering it does not give more CPU to anyone; it shortens the tax in §7.
- Lowering it too far is actively harmful (§6, §7). The optimum is workload-specific and must be measured, not reasoned about.
5. What releases the GIL — and what doesn't¶
§4 explains yielding under duress. The far more important case in real programs is voluntary release: code that gives up the GIL because it is about to do something that doesn't need the interpreter. This is the entire reason threaded Python is useful for I/O, and the entire reason "just use threads for CPU work" fails.
The C-level idiom:
Py_BEGIN_ALLOW_THREADS /* expands to: { PyThreadState *_save = PyEval_SaveThread(); */
result = some_blocking_or_expensive_c_call(...); /* NO Python API in here. None. */
Py_END_ALLOW_THREADS /* PyEval_RestoreThread(_save); } */
Between those macros the thread holds no GIL and has no thread state. Touching any
PyObject — including an innocent-looking Py_DECREF — is undefined behaviour, and the
crash usually lands somewhere unrelated much later.
The table¶
| Releases the GIL | Notes |
|---|---|
Blocking I/O: socket, file, os.read/write, select/epoll |
the reason threading works for I/O at all |
time.sleep() |
including sleep(0), which is a yield hint |
threading.Lock.acquire(), Condition.wait(), Queue.get() |
so lock contention is not GIL contention |
subprocess / os.waitpid |
|
hashlib digests over ~2 KB |
below the threshold it keeps the GIL — the release itself costs more than the hash |
zlib / bz2 / lzma compress & decompress on large buffers |
|
| Most NumPy/SciPy array ops, BLAS calls | but not dtype=object arrays, and not tiny arrays where overhead dominates |
re matching |
no — see below |
Well-written extensions: lxml, Pillow, cryptography, orjson (partly) |
check each one; it is not automatic |
| Does not release the GIL | Consequence |
|---|---|
| Any pure-Python code | obviously — that's what the GIL protects |
re.match / re.search on a pathological pattern |
catastrophic backtracking freezes the whole process |
Very large int ↔ str conversions |
3.11+ caps this by default (sys.set_int_max_str_digits) — that limit is partly a DoS fix and partly a GIL-freeze fix |
Big sorted() / list.sort() with a Python key |
key calls re-enter the eval loop, so it does yield; with a C-level comparison it may not |
json.dumps of a huge structure (C accelerator) |
one long GIL-holding call |
| Extensions that simply never call the macros | the default state of naive C/Cython code |
The practical rule¶
A thread is useful in CPython exactly to the extent that it spends its time with the GIL released. That is the whole model. Everything else — the convoy effect,
ThreadPoolExecutorsizing, whymultiprocessingexists — is downstream of that one sentence.
Two corollaries worth internalizing:
ThreadPoolExecutorfor CPU-bound work in the GIL build is not a small inefficiency — it is negative value. You pay context switches, cache thrash, and the §7 tax to get strictly less throughput than aforloop. Reach forProcessPoolExecutor, a GIL-releasing extension, or a free-threaded build.- In Cython,
with nogil:is how you sayPy_BEGIN_ALLOW_THREADS, and the compiler will refuse to let you touch Python objects inside it. That compile-time check is the single best ergonomic argument for Cython over hand-written C here.
6. OS interaction: mutexes, condvars, futexes, and the scheduler¶
Follow pthread_cond_timedwait down one more layer.
On Linux, a pthread mutex is a futex (fast userspace mutex). Uncontended, locking is
a single CAS in userspace — no syscall, ~20 cycles. Contended, the thread calls
futex(FUTEX_WAIT), which parks it in the kernel and removes it from the run queue.
Waking is futex(FUTEX_WAKE).
So a GIL handoff, in the contended case, costs:
drop_gil → cond_signal → futex(FUTEX_WAKE) ~ syscall, 100s of ns
↓
kernel marks Thread B runnable, places on run queue
↓
EEVDF/CFS decides when B actually runs ~ scheduling latency, µs–ms
↓
context switch: save/restore registers, ~ 1–5 µs direct
switch page tables (or not, same process),
TLB and cache warm-up on the new core ~ 10s of µs *indirect*
↓
Thread B resumes in take_gil()
Three things staff-level engineers should take from this:
The indirect cost dominates. The direct register save/restore is small. The expensive part is the cold cache and TLB on the new core — the thread's working set is somewhere else now. This is why setting the switch interval very low backfires (see the table in §7: below ~10 µs, throughput drops).
The OS scheduler is not cooperating with you. It knows nothing about the GIL. It may
place the woken thread on a different core, a different NUMA node, or an SMT sibling of a
busy core. It may not run it immediately at all. CPython requests a wakeup; the kernel
decides. Under cgroup CPU quota (06-processes-threads-scheduling.md), it may be
throttled entirely — which is a classic source of "GIL contention" that is actually
container throttling. Check cpu.stat's nr_throttled before you blame the GIL.
There is no fairness guarantee. The condvar wait queue plus the drop-request mechanism admits starvation: a thread can request the GIL, have the holder yield, and then lose the reacquisition race to a third thread — or even to the thread that just dropped it. This is a known unfairness in the design, and it is the seed of §7.
7. The convoy effect, measured¶
This is the most important practical GIL pathology, and Dave Beazley's presentation of it (2009–2010, and bpo-7946) is the canonical treatment. Note that this is a pathology of the new GIL — Pitrou's fix for the GIL battle (§3) created it.
The setup: one I/O-bound thread (a socket server) plus some CPU-bound threads.
The trap: an I/O-bound thread has an extremely short GIL residency — it wakes, does a few bytecodes, hits a socket call, and releases the GIL. That is exactly the behaviour you want to reward. Instead:
- I/O thread completes its
recv. It callstake_gil(). - The CPU-bound thread holds the GIL and is happily running bytecode.
- The I/O thread must wait the full switch interval (5 ms) before it is even allowed
to set
gil_drop_request. - Only then does the CPU thread yield — and the I/O thread might still lose the race.
- The I/O thread runs for microseconds, blocks on I/O again, and the whole cycle repeats.
The latency-sensitive thread is penalized precisely because it is well-behaved. A 5 ms tax on every single I/O completion.
Measured throughput of an echo-style server (requests/sec), varying switch interval and CPU-bound thread count:
| Switch interval (s) | 0 CPU threads | 1 CPU thread | 2 CPU threads | 4 CPU threads |
|---|---|---|---|---|
| 0.1 | 30,000 | 5 | 2 | 0 |
| 0.01 | 30,000 | 50 | 30 | 15 |
| 0.005 (default) | 30,000 | 100 | 50 | 30 |
| 0.001 | 30,000 | 500 | 280 | 200 |
| 0.0001 | 30,000 | 3,200 | 1,700 | 1,000 |
| 0.00001 | 30,000 | 11,000 | 5,500 | 2,800 |
| 0.000001 | 30,000 | 10,000 | 4,500 | 2,500 |
Read that first data column against the second. 30,000 → 100 RPS from adding one CPU-bound thread. A 300× collapse. Not a 2× slowdown from sharing a core — a three-orders-of-magnitude collapse, caused entirely by the switch-interval tax.
Then read the last row: pushing the interval to 1 µs makes things worse than 10 µs, because now context-switch cost (§6) dominates. There is an optimum, it is workload- specific, and it is roughly 100–500× smaller than the default.
Why the default is still 5 ms. Because the trade is real in the other direction: a small interval taxes throughput workloads with switch overhead, and the vast majority of Python processes are not mixed I/O + CPU. 5 ms is a defensible default for the common case and a terrible one for yours. That is a tuning parameter doing its job, not a bug.
What to do about it in production:
- Don't mix CPU-bound and latency-sensitive I/O threads in one interpreter. This is the real fix. Separate processes, or push CPU work into a GIL-releasing extension.
sys.setswitchinterval(0.0001)is a legitimate mitigation for I/O-latency-sensitive services — measure it, don't cargo-cult it, and re-measure after any dependency bump.- Recognize the signature: p99 latency quantized near multiples of 5 ms, with low overall CPU utilisation, is a GIL convoy fingerprint. If your latency histogram has a suspicious cliff at 5 ms, this is your first hypothesis. §16 is how you confirm it.
- asyncio is not immune. The event loop is one thread, so coroutines never fight each
other for the GIL — but the moment you add a
ThreadPoolExecutorfor "blocking" work that turns out to be CPU-bound, the loop thread becomes the I/O-bound victim in exactly the scenario above, and your whole service's tail latency quantizes to the switch interval.
8. Signals, Ctrl-C, and fork¶
Two adjacent behaviours that people file under "weird Python threading bugs" and are really just §4 and §5 seen from a different angle.
Signals¶
A POSIX signal can be delivered to any thread, but Python's signal handlers are Python functions — they need the GIL and a thread state. So CPython does this:
- The real C signal handler (
signal_handlerinModules/signalmodule.c) does almost nothing: it records which signal arrived in a flag array and sets_PY_SIGNALS_PENDING_BITon the eval breaker. It is async-signal-safe by being trivial. - Only the main thread of the main interpreter runs the Python-level handler, at its next eval-breaker check point.
Three consequences:
Ctrl-Crequires the main thread to be executing Python bytecode. If it is blocked in a C call that holds the GIL (§5's second table), nothing happens — the flag is set and never read. This is the "Ctrl-C does nothing" experience, and it is whyCtrl-Cworks fine while the main thread is intime.sleep()(GIL released, interruptible) but not while it is in a runaway regex.KeyboardInterruptlands wherever the main thread happens to be, which is effectively a random line. Code that must be interrupt-safe cannot assume a clean point.- Worker threads cannot be interrupted this way at all. There is no "cancel this
thread" in Python.
PyThreadState_SetAsyncExcexists (it sets_PY_ASYNC_EXCEPTION_BIT) and is whatctypes-based "kill thread" recipes use, but it suffers the same limitation — the target must reach a check point — and it can leave locks held andfinallyblocks unrun. Do not build on it. Use a cooperative cancellation flag, or a process.
signal.set_wakeup_fd() is the escape hatch that lets an event loop learn about a signal
via its selector rather than via the eval breaker; it is how asyncio handles signals.
fork¶
os.fork() copies only the calling thread. Every mutex held by any other thread at the
moment of the fork stays locked forever in the child, with no owner to release it.
The GIL itself is handled — PyOS_AfterFork_Child() reinitializes it and the runtime's
own locks, and re-registers the child's thread state as the main thread. What is not
handled is every other lock in your process: logging's handler locks, an allocator's
internal locks inside a C library, a connection pool's mutex, random's state lock. The
child deadlocks the first time it touches one.
This is why:
- Python 3.12 added a
DeprecationWarningwhenos.fork()is called in a process with multiple threads. Take it seriously; it is a real bug class, not lint. - Python 3.14 changed
multiprocessing's default start method on Linux fromforktoforkserver. macOS moved tospawnback in 3.8. If you have code that silently relied on inheriting state throughfork, 3.14 breaks it — and that break is the point. os.register_at_fork()exists for libraries that must reinitialize state; use it rather than hoping.
The connection to §1: even when fork works, refcount write-amplification erodes the CoW saving that motivated using it.
9. What the GIL does and does not guarantee¶
It does guarantee¶
- One thread executes bytecode at a time.
- A single bytecode instruction implemented entirely in C, that does not call back into Python, completes without another Python thread interleaving.
- Reference counts do not get corrupted.
- Interpreter-internal structures stay consistent.
It does not guarantee¶
Your multi-bytecode operation is atomic. The universal example:
Four instructions, three yield opportunities. Two threads doing this a million times each will lose updates. This is the difference between a data race (memory corruption at the hardware level — the GIL does prevent this) and a race condition (logical interleaving — the GIL does nothing).
Anything calling back into Python is atomic. d[k] = v on a plain dict with a
str key is effectively atomic. On a dict whose key has a Python-level __hash__, it
is not — the interpreter re-enters the eval loop and can yield mid-operation. The same
applies to __eq__, __index__, __del__, and any @property.
Your __del__ runs at a predictable time or on a predictable thread. A refcount can
hit zero on any thread, so a finalizer can execute on a thread that has never heard of
that object, holding locks that thread didn't expect to interact with. This is a real
deadlock source; see 22-garbage-collection.md.
The atomicity table — and the trap¶
| Operation | GIL build | Free-threaded build |
|---|---|---|
lst.append(x) |
atomic | atomic (per-object lock) |
d[k] = v (builtin key type) |
atomic | atomic (per-object lock) |
x += 1 (int) |
not atomic | not atomic |
d[k] += 1 |
not atomic | not atomic |
lst[i] = lst[j] |
atomic | atomic |
if k not in d: d[k] = v |
not atomic | not atomic |
obj.attr += 1 |
not atomic | not atomic |
lst.sort() with a Python key= |
not atomic | not atomic |
The trap this table exists to spring: notice the free-threaded column is the same. People expect removing the GIL to break their code. Mostly it doesn't — because the things that were atomic were atomic due to C-level indivisibility, which per-object locking preserves, and the things that weren't atomic were already broken. Free- threading doesn't create new race conditions in Python code so much as it raises the probability of ones you already had from "once a month in prod" to "immediately".
The genuinely new hazards live in C extensions (§14), not in Python-level code.
Never reason from this table in application code. It documents an implementation
detail, not a language guarantee, and it has shifted before (dict internals changed in
3.6, 3.11 and 3.14). Use a Lock, or itertools.count(), or an immutable
accumulate-then-merge pattern. The table is for reading other people's code and for
diagnosing incidents — not for writing new lock-free code.
10. Sub-interpreters: one GIL each (PEP 684 / PEP 734)¶
There is a third answer to "the GIL limits me to one core", between multiprocessing and
free-threading, and it is easy to miss because it took a decade to become usable.
PEP 684 (Python 3.12) made the GIL per-interpreter. Everything in §2's struct
moved from process-global to PyInterpreterState for interpreters created with
PyInterpreterConfig.gil = PyInterpreterConfig_OWN_GIL. PEP 734 (Python 3.14) put a
supported Python API on top: the concurrent.interpreters module.
from concurrent import interpreters
interp = interpreters.create()
interp.exec("import math; print(math.factorial(20))")
Why this is not just "threads with extra steps"¶
Each interpreter has its own module state, its own sys.modules, its own builtins, its
own GC, its own allocator arenas — and, critically, its own object graph. Two
interpreters never share a PyObject, which is precisely why they can run bytecode
simultaneously on different cores without any of §1's coherence problem. The isolation
is the mechanism.
The cost model sits neatly between the other two:
| Threads (GIL build) | Sub-interpreters | Processes | |
|---|---|---|---|
| Parallel bytecode | ✗ | ✓ | ✓ |
| Startup | ~50 µs | ~ms (re-imports modules) | ~10–100 ms (spawn) |
| Memory per unit | ~8 MB stack | interpreter + its own copy of every imported module | full process |
| Sharing objects | free | impossible | impossible |
| Sharing data | free | buffers (zero-copy) + pickled values via Queue |
pickle / shared_memory |
| Crash blast radius | whole process | whole process | one process |
| C extension support | universal | requires multi-phase init (PEP 489) + Py_mod_multiple_interpreters |
universal |
The two traps¶
1. Memory does not amortize the way you expect. Each interpreter imports its own copy of every module. Ten interpreters each importing NumPy and Pandas is close to ten times the module-level memory of one — you avoided the process overhead, not the import overhead. Immortal statics and some interned strings are shared; your third-party dependency tree is not.
2. The extension gate is the real blocker, and it's the same shape as free-threading's.
An extension must use multi-phase initialization and declare
Py_mod_multiple_interpreters support, or importing it in a sub-interpreter raises
ImportError. Compare this with §14's Py_mod_gil: both projects ended up needing the
ecosystem to explicitly declare "I hold no process-global mutable state." That is not a
coincidence — it is the same latent bug being surfaced by two different mechanisms.
Where sub-interpreters win over processes: no serialization boundary for buffers, much
faster startup, and a single process to deploy and observe. Where they lose: no fault
isolation, a much smaller compatible-extension universe, and a memory profile that
surprises people. Full treatment in 27-multiprocessing-and-subinterpreters.md.
11. The Gilectomy: Larry Hastings' seven-core lesson¶
Larry Hastings' Gilectomy (2015–2018) is the most instructive failure in CPython's history, and every engineer who says "just remove the GIL" should be made to read it.
Attempt 1: make refcounts atomic¶
The obvious approach. Replace ++obj->ob_refcnt with an atomic increment.
Result: roughly a 30% slowdown — and it got worse with more threads.
Exactly §1, playing out in production code. The atomic RMW instructions destroyed cache consistency and flooded the intercore bus with coherence traffic. The GIL wasn't just protecting refcounts; its absence turned every refcount into a cross-core transaction.
Note the shape of the failure: the slowdown scaled the wrong way. Adding cores made it
worse. That is the signature of a coherence problem rather than a lock-contention problem
— and telling those two apart from a flame graph is a genuine staff-level skill. (Lock
contention shows up as time parked in futex waits; coherence shows up as stalled cycles
and rising mem_load_l3_miss_retired / HITM counters with no corresponding wait time.)
Attempt 2: buffered reference counting¶
Hastings borrowed buffered reference counting from GC research: don't update the refcount at all. Append the operation to a thread-local log and reconcile later. No shared writes → no coherence traffic.
Py_INCREF became:
static inline void Py_INCREF(PyObject *o)
{
PyRefLog *rl = PyThread_get_key_value(PyRefLogTLSKey);
if (PyRefPad_IsFull(rl)) {
PyRefLog_Rotate(rl);
}
PyRefPad_Write(rl->incref, o);
}
Two details worth pausing on:
Py_DECREFuses a separate log (rl->decref). Increments and decrements must be replayed respecting order, or an object could be freed while still referenced. Ordering is not optional even when the counting is deferred.- The logs are thread-local (
PyThread_get_key_value) specifically to dodge contention. The first version had a shared log; contention on the log itself forced segregation by thread and by operation.
Compare the cost of that Py_INCREF with the original. The original was one non-atomic
add on a line already in L1. The replacement is a TLS lookup, a bounds check, a
predictable branch, and a store to a log that will later be walked again. The fast path
got several times more expensive so that the slow path could get cheaper — and in
CPython the fast path runs billions of times.
The result¶
Hastings' October 2016 measurement: the buffered-refcount build reached performance parity with stock CPython — while using about seven CPU cores to match stock CPython on one.
That number is the entire lesson. Not "it was 30% slower". Seven cores to break even.
Why it ultimately didn't ship¶
By 2018 the direction under consideration was replacing refcounting with a tracing garbage collector and breaking the C API — an enormous ecosystem cost. It didn't happen.
The three transferable lessons:
- You cannot bolt thread-safety onto a design whose hot path assumes single-threaded mutation. The refcount is not an implementation detail; it is the architecture.
- Deferring work does not delete it. Buffered refcounting moved the cost from coherence traffic to log processing, memory pressure, and reconciliation. It traded one bottleneck for another.
- "Works, but needs 7× the hardware" is a failure. Systems work that doesn't respect the single-threaded baseline gets rejected — correctly.
12. What Sam Gross did differently (PEP 703)¶
The accepted approach (Sam Gross, nogil → PEP 703, accepted Oct 2023) succeeded because
it attacked the problem from five directions simultaneously, rather than trying to
make one mechanism carry it.
The structural insight: there is no single replacement for the GIL, because objects are not all alike. Sort objects by how they're actually used, and each class gets a different — cheaper — treatment. Tier 1 pays nothing, tier 2 pays a branch, tier 3 pays at GC time, and only what's left pays for a lock.
12.1 Immortalization — the "don't count at all" tier¶
The hottest refcounts belong to objects that never die: None, True, False, small
ints, interned strings, statically allocated type objects. PEP 703 marks these by setting
the local refcount field to a sentinel:
Py_INCREF and Py_DECREF become no-ops for them. Per the PEP: "This avoids
contention on the reference count fields of these objects when multiple threads access
them concurrently."
You can see this from Python, on any 3.12+ build:
This is the single highest-leverage change. Go back to §1: the pathological case was N
cores ping-ponging None's refcount. Immortalization deletes that case entirely — those
lines can now sit in Shared MESI state on every core forever, read-only, never
invalidated. It also fixes the fork/CoW problem from §1's coda, which is why it landed
in the GIL build too.
(Related but distinct from PEP 683, adopted in 3.12, which introduced immortal
objects for the GIL build; PEP 703 uses a different bit representation so it composes
with biased and deferred refcounting. Note the cost side: immortality is a branch in
Py_DECREF that every object now pays, which is a small but real regression in the GIL
build — accepted because the CoW and contention wins dominate.)
12.2 Biased reference counting — the "count locally" tier¶
Borrowed from Swift. Split the count in two:
| Field | Written by | How |
|---|---|---|
ob_ref_local |
the owning thread only | plain non-atomic instructions |
ob_ref_shared |
any other thread | atomic instructions |
ob_tid |
— | the owning thread's id, for the fast-path check |
The fast path — object created and used by one thread, which is the overwhelming majority
of objects — is if (ob_tid == current_thread_id) then a non-atomic increment.
Same instruction as the GIL build. Zero coherence traffic.
This answers the natural objection ("doesn't checking ownership cost a field and a
branch?") — yes, and it is worth it: one predictable, perfectly-predicted branch and an
L1 hit on a line you were about to touch anyway, versus a cross-core lock xadd. The
branch predictor makes the check nearly free; nothing makes the atomic free.
When a second thread touches the object, it escapes: it is marked shared, and both counts are eventually merged. Escape is one-way and permanent for that object. An object that gets handed to another thread once is on the slow path for the rest of its life — which is why §13's "sharing wall" is about object graphs, not about momentary contention.
12.3 Deferred reference counting — the "count later" tier¶
For objects that are read constantly from many threads but rarely die — top-level functions, modules, classes, heap types — even biased refcounting escapes to the shared path because they're genuinely cross-thread. Deferred refcounting skips refcount updates for these when the reference lives only on the interpreter stack, and reconciles during GC by scanning stacks.
The trade: it removes the hottest remaining atomics, but it makes the cycle collector's job mandatory rather than opportunistic — an object with deferred references cannot be freed by refcount alone. That is one reason §12.5 exists.
12.4 Per-object locking, QSBR, and mimalloc¶
Container mutations take a per-object lock. Reads mostly don't. Sam Gross estimated this "automatic fine-grained locking" at about 1.5% overhead.
Lock-free reads raise an immediate question: if a reader is walking a dict's key table
with no lock, what stops a concurrent writer from resizing and freeing that table out
from under it? The answer is QSBR (Quiescent State Based Reclamation, borrowed from
FreeBSD, in Python/qsbr.c). Memory that a lock-free reader might still be pointing at
isn't freed immediately — it goes on a deferred list (_PyMem_FreeDelayed) and is
reclaimed only once every thread has passed through a quiescent state, proving no reader
can still hold a pointer to it. It is the same idea as RCU in the Linux kernel. The cost
is bounded memory latency: freed memory stays allocated a little longer.
This is where mimalloc earns its place: it isn't just "a thread-safe allocator" (as an LWN commenter correctly noted, any serious allocator is). It was chosen because its internal structure — size-segregated heaps and pages — lets the GC traverse all objects without a global registry, and lets lock-free readers find an object's metadata safely. The allocator choice is load-bearing for the GC design, not just for allocation speed.
12.5 Stop-the-world for cycle collection¶
The cycle collector needs stable refcounts. Under the GIL it got that for free. Without
it, PEP 703 pauses all Python-executing threads. Per the PEP: two stop-the-world pauses
per collection — one to find cyclic trash, one after finalizers to confirm what's still
unreachable — with threads resumed before finalizers and tp_clear run, specifically
to avoid introducing deadlocks that don't exist under the GIL.
So: free-threaded Python has real stop-the-world GC pauses that the GIL build does not.
That is a genuine, new, workload-dependent latency cost, and it scales with thread count
(every thread must reach a safepoint before the pause can begin — the same "slowest
thread sets the pace" problem every STW runtime has). See 22-garbage-collection.md.
12.6 The design is almost entirely borrowed — and that's the point¶
Sam Gross's own list of provenance:
| Component | Taken from |
|---|---|
| Biased reference counting | Swift |
| mimalloc | Koka / Lean |
| Internal lock design | WebKit (locking in WebKit) |
QSBR (Python/qsbr.c) |
FreeBSD |
| Interpreter (register-accumulator model, fast calls) | V8 Ignition, LuaJIT |
| Stop-the-world implementation | Go's runtime |
The Gilectomy tried to invent a mechanism. PEP 703 assembled proven ones. That contrast is the meta-lesson of this whole document.
13. Free-threading's new cost model¶
Where it stands (verify against your interpreter — this moves)¶
- Officially supported since 3.14 (PEP 779 phase II), experimental in 3.13. Still not the default build. "GIL off by default" is phase III, with no committed date.
- Single-threaded overhead, per the official docs: on pyperformance, from about 1% on macOS aarch64 to 8% on x86-64 Linux. Note the platform spread — it reflects how much the weaker-ordered ARM memory model and different cache topology change the cost of the same code.
- Ignore benchmark numbers from 2024. 3.13's free-threaded build ran with the adaptive specializing interpreter largely disabled (specialization wasn't thread-safe yet), which dominated its reported overhead. 3.14 re-enabled thread-safe specialization. Any figure you find that predates that describes a build nobody should benchmark now.
- Check at runtime with
sys._is_gil_enabled()(3.13+). Free-threaded builds carry thetABI tag (cp314t) and definePy_GIL_DISABLED:
import sys, sysconfig
sys._is_gil_enabled() # False on a free-threaded build...
sysconfig.get_config_var("Py_GIL_DISABLED") # ...but this tells you which BUILD you're on
Those two can disagree — a free-threaded build can be running with the GIL on, because
of -X gil=1 / PYTHON_GIL=1, or because an extension re-enabled it at import (§14).
When someone reports "free-threading didn't help", this is the first thing to check.
The cost model actually changed shape¶
| GIL build | Free-threaded build | |
|---|---|---|
| Refcount update (own thread) | non-atomic | non-atomic (biased fast path) |
| Refcount update (shared object) | non-atomic | atomic + coherence traffic |
None/small ints/interned strs |
non-atomic write | free (immortal) |
| Container mutation | free (GIL) | per-object lock (~1.5%) |
| Cycle GC | no STW pause | 2 STW pauses per collection |
| Object header | 16 bytes | larger (see 16-object-memory-layout.md §2) |
| Scaling limit | 1 core of bytecode | shared-object refcount contention |
The new scaling wall is object sharing. Not the GIL — sharing. A workload where threads work on disjoint object graphs scales beautifully. A workload where all threads hammer one shared dict or one shared instance will hit refcount escape, shared-count atomics, coherence ping-pong, and per-object lock contention — and may scale barely better than under the GIL, while paying the single-threaded tax.
This is the most important practical takeaway in the document: removing the GIL moved
the bottleneck from "one lock" to "the memory system", and the memory system's rules are
the ones in 01 and 02. You are now writing code whose performance is governed by
cache coherence. That is why Tier 0 comes first in this roadmap.
The design rules that follow¶
Once sharing is the bottleneck, the optimizations are the ones you'd apply in any shared-memory language:
- Partition the data, not the work. Give each thread its own slice of the object graph and merge at the end. This keeps biased refcounting on its fast path.
- Share immutable, share once. Objects read by all threads should be immortal or effectively so — module-level constants, frozen dataclasses created before the threads start. What kills you is the shared mutable dict every worker writes to.
- A shared counter is a shared cache line. The Python-level fix is per-thread counters summed at the end — literally §1's lab exercise, in Python.
- Measure with thread count, not just wall time. The diagnostic signature of a coherence problem is that adding threads makes per-thread throughput fall super-linearly. If 8 threads give you 2× of 1 thread, you have a sharing problem, not a "Python is slow" problem.
14. C extensions under free-threading¶
This is where the genuinely new breakage lives, and it deserves its own section because almost every real free-threading migration failure is here rather than in Python code.
The opt-in gate¶
static PyModuleDef_Slot module_slots[] = {
{Py_mod_gil, Py_MOD_GIL_NOT_USED}, /* "I am free-threading safe" */
{0, NULL}
};
An extension without this slot causes CPython to re-enable the GIL at runtime when it
is imported (unless PYTHON_GIL=0 forces it off). It works, it warns, and it silently
erases the entire benefit of running a free-threaded build. PyUnstable_Module_SetGIL()
is the equivalent for single-phase-init modules.
This is a performance cliff you must monitor for. One transitive dependency updating
into a non-declaring build, and your carefully migrated service quietly reverts to
GIL-build behaviour with free-threading's single-threaded tax still applied — the worst
of both. Assert on sys._is_gil_enabled() at startup in production.
What actually breaks¶
- The GIL was your module's mutex, and you didn't know it. Any module-level mutable C state — a cache, a counter, a lazily-initialized static, an interned-string table — was protected for free. It no longer is. This is the dominant bug class.
- Borrowed references become significantly more dangerous.
PyList_GetItemreturns a borrowed reference; under free-threading the list can be mutated concurrently and the object freed before you use it. Prefer the strong-reference APIs:PyList_GetItemRef,PyDict_GetItemRef,PyObject_GetOptionalAttr. Most of these were added in 3.13 precisely for this. - "Atomic because it's one C call" no longer implies "atomic across two C calls."
PyDict_Containsfollowed byPyDict_SetItemis a race; it always was, but the GIL made the window zero.
Critical sections¶
The supported tool for "I need this object stable across several operations":
Py_BEGIN_CRITICAL_SECTION(obj);
/* obj's per-object lock is held */
Py_END_CRITICAL_SECTION();
Py_BEGIN_CRITICAL_SECTION2(a, b); /* two objects, deadlock-ordered for you */
Py_END_CRITICAL_SECTION2();
These compile to no-ops in the GIL build, so one source tree serves both.
The subtlety that catches people: a critical section is not a plain mutex. If the thread suspends inside it — blocking on another lock, or being stopped for a stop-the-world GC pause — the critical section is released and reacquired. That is deliberate: it makes deadlock structurally impossible, which is why the API can be applied mechanically across the interpreter. But it means your invariants can be broken across any suspension point inside the section. Critical sections give you atomicity against other threads doing ordinary work; they do not give you a transaction.
15. Choosing a concurrency model¶
The decision most teams actually need, with the GIL as one input among several.
| Best for | Parallel CPU | Isolation | Data sharing | Main cost | |
|---|---|---|---|---|---|
| asyncio | many concurrent I/O ops, high connection counts | ✗ | none | free | one blocking call stalls everything; async ecosystem lock-in |
| Threads (GIL build) | blocking I/O, GIL-releasing native calls | ✗ | none | free | §7 convoy; ~8 MB stack each |
| Threads (free-threaded) | CPU work over shared data structures | ✓ | none | free | 1–8% single-thread tax; sharing wall (§13); extension support |
| Sub-interpreters | CPU work, isolated, low startup | ✓ | partial | buffers + pickle | per-interpreter imports; extension support (§10) |
| Processes | CPU work, untrusted or crash-prone code | ✓ | full | pickle / shared_memory |
startup, memory, serialization |
Native extension w/ nogil |
numeric / bulk data work | ✓ | none | free | you have to write it |
How to actually decide¶
- Measure first. Is the process CPU-bound or I/O-bound?
py-spy topfor a minute answers this and is the step people skip. Most "GIL problems" are neither. - If it's I/O-bound: threads or asyncio. The GIL is not your problem; §7 might be.
- If it's CPU-bound and the hot loop is numeric: push it into NumPy / a Rust or C extension that releases the GIL. This is almost always the highest return per hour of effort, and it works on every Python you'll ever deploy to.
- If it's CPU-bound, pure Python, and embarrassingly parallel: processes. Boring, universally supported, isolates crashes.
- If it's CPU-bound, pure Python, and needs a large shared working set (the case where pickling dominates): this is exactly what free-threading is for. Check your extension dependencies first, then measure the single-threaded regression, then measure scaling.
Note the ordering: free-threading is fifth, not first. It is the right answer for a real and previously unserved case — parallel CPU work over a shared object graph too large to copy — and the wrong answer for most workloads that merely feel GIL-bound.
16. Diagnosing GIL problems in production¶
§7 tells you the fingerprint. This is how you confirm it rather than guess.
py-spy is the first tool, always. It attaches to a running process without
modifying it.
py-spy top --pid 1234 # live view; the %GIL column is the whole answer
py-spy top --pid 1234 --gil # only sample threads that currently hold the GIL
py-spy dump --pid 1234 # stack of every thread, right now
py-spy record --pid 1234 -o p.svg --idle # flamegraph including waiting threads
Read it like this:
| Observation | Diagnosis |
|---|---|
| One thread at ~100% GIL, others near 0 | classic GIL saturation — you are CPU-bound in Python |
| Total GIL% high, spread across threads | GIL-bound; free-threading or processes will help |
| Total GIL% low, wall time high | not the GIL — you're blocked on I/O, a lock, or the scheduler |
| Every thread parked in the same C call | a native library serializing internally |
| Process CPU capped well below the limit | check cgroup throttling (§6) before anything else |
gil_load (Chris Billington) measures GIL wait/held fractions from inside the process
and gives you a number to alert on, rather than an impression.
Confirm the convoy effect specifically by perturbing the variable and watching the outcome — this is the cheapest decisive experiment in the whole document:
If p99 latency drops sharply, you had a convoy. If nothing changes, stop blaming the GIL and go look at §6's scheduler and cgroup causes. A hypothesis that survives a 50× perturbation of its supposed cause was never the explanation.
Other signals worth wiring up:
- Latency histograms with enough resolution to see quantization at 5 ms. If your
histogram buckets are
[1ms, 10ms, 100ms]you cannot see this at all — that's a monitoring bug that hides a class of production bug. sys._is_gil_enabled()asserted at startup on free-threaded deployments (§14).perf stat -e cache-misses,mem_load_l3_miss_retired.remote_hitmwhen you suspect §1's coherence problem rather than lock contention. Coherence problems show stalls without waits; lock contention shows waits.PYTHONFAULTHANDLER=1plusfaulthandler.dump_traceback_later()to catch the §4 consequence-2 case where the process goes fully dark and evenpy-spycan't tell you much beyond "it's in C."
17. The GIL elsewhere: other implementations¶
Useful because it isolates the variable: implementations without refcounting mostly don't have this problem, which is the strongest available evidence for §1's central claim.
| Implementation | GIL? | Why |
|---|---|---|
| CPython | yes (optional since 3.13) | reference counting |
| PyPy | yes | also refcounts at the RPython level; its STM branch (2014–2016) reached working parity at ~2× single-thread overhead and was abandoned for lack of funding and complexity — a second data point for §11's lesson |
| Jython | no | JVM GC, no refcounts; true threading, and it inherits Java's memory model |
| IronPython | no | .NET GC, same reasoning |
| GraalPy | no for pure Python | JVM/Truffle GC; falls back to a GIL-like lock when running native extensions through its C API emulation |
| MicroPython | yes | for entirely different reasons (simplicity on microcontrollers) |
The pattern is exact: tracing GC → no GIL; reference counting → GIL. Jython and IronPython have been GIL-free for two decades and nobody used them for that, which is its own lesson — the C extension ecosystem is a stronger constraint than parallelism.
18. Lab exercises¶
Do these. Reading this document leaves you at rung 3 (see the ladder in README.md).
-
Feel the coherence cost. In C: N threads incrementing (a) thread-local counters, (b) separate counters on the same cache line, © one shared atomic counter. Plot throughput vs N. You should see roughly flat, collapsed, and collapsed-worse. This is the Gilectomy's first attempt, in 30 lines.
-
Reproduce the convoy effect. A socket echo server thread + a
while True: passthread. Measure RPS. Then sweepsys.setswitchinterval()from0.1to0.000001and reproduce the table in §7 on your own hardware. Explain why the smallest value is not the best. -
Prove
x += 1isn't atomic. Two threads, one shared counter, one million increments each. Print the result. Thendisthe function and point at the exact instruction boundary where the interleaving happens. Re-run on a free-threaded build and observe that it fails faster. -
Freeze the interpreter. Write a C extension (or use a pathological regex) that holds the GIL for 30 seconds. Confirm
Ctrl-Cdoes nothing and other threads make no progress. Then wrap it inPy_BEGIN_ALLOW_THREADSand confirm the difference. Then explain, from §8, whyCtrl-Cworked duringtime.sleep(30)but not here. -
Find the check points. Write a function with a long straight-line body and no loops or calls, and one with a tight loop.
disboth, locateRESUMEandJUMP_BACKWARD, and predict which one a second thread can interrupt. Verify. -
The five-way comparison. One CPU-bound workload; implement with threads on a GIL build, threads on a free-threaded build,
multiprocessing,concurrent.interpreters, and anogilnative extension. Build a table: wall time, total CPU, RSS, startup cost, lines of code. This artifact is the single best interview asset in Tier 4. -
Find the sharing wall. Take exercise 6's free-threaded version and make the threads share one large dict instead of working on disjoint data. Measure the scaling difference. You have just measured §13's new bottleneck.
-
Break the GIL-as-mutex assumption. Write a C extension with a module-level cache and no locking. Confirm it is correct under the GIL, then run it on a free-threaded build under load and corrupt it. Fix it with
Py_BEGIN_CRITICAL_SECTION. This is the migration risk in §14, reproduced in miniature. -
Diagnose someone else's process. Take any real service you run, attach
py-spy topfor 60 seconds, and write down: GIL%, the top holder, and whether it is CPU-bound, I/O-bound, or throttled. Most engineers have never done this once.
19. Question bank¶
Staff-level. If you can't answer from your own model, the section to reread is noted.
- Why is atomic reference counting slower than a global lock for single-threaded code? (§1)
- Two threads only read a shared object. Why does this generate write traffic on the memory bus? (§1)
- Why does
os.fork()fail to deliver the copy-on-write savings people expect from it? (§1, §8) - What was wrong with the pre-3.2 GIL, and why didn't
sys.setcheckintervalfix it? (§3) - What is
switch_numberfor? What breaks if you remove it? (§3) - Is the switch interval a quantum or a timeout? What is the actual end-to-end latency to a thread switch? (§4, §6)
- Between which bytecodes can a thread switch happen — and where can it not? (§4)
- Why can't
Ctrl-Cinterrupt a long-running C extension call, but it can interrupttime.sleep(60)? (§5, §8) - Name three stdlib operations that release the GIL and three that don't. What's the rule? (§5)
- Explain the convoy effect end to end, and predict the p99 latency signature it produces. (§7)
- Pitrou's new GIL fixed the GIL battle and created the convoy effect. What was the trade? (§3, §7)
- Your service's p99 is 5.2 ms and CPU is at 30%. What is your first hypothesis, and what single experiment confirms or kills it? (§7, §16)
- Why did Larry Hastings' Gilectomy need seven cores to match stock CPython on one? (§11)
- What is buffered reference counting, and why did it need two separate logs? (§11)
- From a flame graph, how do you distinguish lock contention from a cache-coherence problem? (§11, §16)
- How does biased reference counting make the common case non-atomic, and what happens when an object escapes? (§12.2)
- Why is immortalizing
Nonemore valuable than any other single optimization in PEP 703? (§12.1, §1) - What is QSBR for, and what would break without it? (§12.4)
- Why was mimalloc chosen — and why is "it's thread-safe" the wrong answer? (§12.4)
- Free-threaded Python introduces stop-the-world GC pauses the GIL build doesn't have. Why is that unavoidable? (§12.5)
- Your service is 8% slower on the free-threaded build and doesn't scale past 2 threads. Diagnose. (§13)
sys._is_gil_enabled()returnsTrueon acp314tbuild. Give three explanations. (§13, §14)- Which is more likely to break under free-threading: your Python code or your C extensions? Why? (§9, §14)
- Why is
Py_BEGIN_CRITICAL_SECTIONnot equivalent to holding a mutex? (§14) - Is
d[k] += 1atomic on a free-threaded build? Justify from bytecode. (§9) - When would you choose sub-interpreters over processes, and over free-threaded threads? (§10, §15)
- Sub-interpreters and free-threading both require extensions to opt in, with different slots. Why did two independent projects arrive at the same requirement? (§10, §14)
- Jython has had no GIL since 2001. Why did that not settle the question? (§17)
20. Sources¶
Primary
- PEP 703 — Making the Global Interpreter Lock Optional in CPython (Sam Gross) — the specification. Read §Reference Counting and §Garbage Collection in full.
- PEP 779 — Criteria for supported status for free-threaded Python — the phase model.
- PEP 684 — A per-interpreter GIL and PEP 734 — Multiple interpreters in the stdlib.
- PEP 683 — Immortal objects.
- Python support for free threading (official HOWTO) and the C API HOWTO — authoritative on current limitations, overhead numbers, and the extension contract.
- CPython sources: Python/ceval_gil.c, Python/ceval.c, Python/bytecodes.c (grep for CHECK_EVAL_BREAKER), Include/object.h, Python/qsbr.c, Python/critical_section.c, Modules/signalmodule.c.
The old GIL and the rewrite
- Dave Beazley — Understanding the Python GIL and Inside the GIL — the origin of both the GIL-battle and convoy-effect analyses. Watch these before reading anything else on the topic.
- bpo-7946 — Convoy effect with I/O bound threads and New GIL — still open. Read the whole thread.
- Antoine Pitrou's python-dev thread on the new GIL (2009) — the design discussion, including the fairness question he explicitly declined to solve.
The Gilectomy - Victor Stinner — Free Threading internals: reference counting — has the actual buffered-refcount source. - LWN — A Gilectomy update (2018) - LWN — Progress on the Gilectomy (2017) - LWN — Gilectomy (2016) - Larry Hastings, The Gilectomy / How's It Going? — PyCon 2016/2017, EuroPython. Watch the talks; the graphs land better than prose.
The GIL itself - Python behind the scenes #13: the GIL and its effects on Python multithreading — the best free deep-dive; source of the §7 measurements.
Free-threading design - LWN — A viable solution for Python concurrency (2021) — including Sam Gross's own comments on provenance and overhead. - Locking in WebKit — where the internal lock design came from. - py-free-threading.github.io — the community porting guide; the practical companion to §14.
Tooling
- py-spy — sampling profiler with a %GIL column.
- gil_load — quantifies GIL held/wait fractions.
Foundations (Tier 0, if any of §1 was unclear) - Ulrich Drepper, What Every Programmer Should Know About Memory - Paul McKenney, Memory Barriers: a Hardware View for Software Hackers - Herlihy & Shavit, The Art of Multiprocessor Programming, 2e — ch. 7 (spin locks & contention)
Next: 25-threads-and-synchronization.md (what to actually build on top of this), then
26-free-threading.md (the migration in depth), then
27-multiprocessing-and-subinterpreters.md (the alternative in §10 and §15).