The GIL and Context Switching
A Python interpreter executes a stream of opcodes: small instructions
that the bytecode compiler produces from your source. a + b becomes
several opcodes (load a, load b, perform the addition, store the
result), and the interpreter runs them one at a time. The GIL controls
how the opcode streams from different threads interleave.
How many opcodes does Python have?
The count depends on how you measure and which Python version you’re running.
Base opcodes (the ones you see in dis output) number roughly 100–130
in Python 3.13/3.14. You can check exactly:
# examples/opmap_contents.py
import opcode
print(f"opmap entries: {len(opcode.opmap)}")
print(f"opname entries: {len(opcode.opname)}")
for num, name in enumerate(opcode.opname):
print(f" {num:3d} {name}")
opmap holds the “public” opcodes visible in dis output; opname
is larger and also includes <N> reserved slots, INSTRUMENTED_*
debugger opcodes, and pseudo-instructions used during compilation.
On top of that, Python 3.11+ added specialized/adaptive opcodes: internal
variants like LOAD_FAST_CHECK and BINARY_OP_ADD_INT that the interpreter
substitutes at runtime for frequently-executed code paths. These add another
~50–60, bringing the total to roughly 180–220 entries in the opcode table.
Why this matters for the GIL demo
The race condition in counter += 1 comes from the fact that it is not
atomic. An operation is atomic if no other thread can observe it
half-done: it has either not started or has finished, never an
in-between state. You can see the opcodes counter += 1 compiles to
with dis:
# examples/dis_increment.py
import dis
def increment():
counter += 1 # pyright: ignore[reportUnboundVariable]
dis.dis(increment)
Output:
4 RESUME 0
5 LOAD_FAST_CHECK 0 (counter)
LOAD_SMALL_INT 1
BINARY_OP 13 (+=)
STORE_FAST 0 (counter)
The columns are: source line number, byte offset, opcode name, numeric argument, and a human-readable annotation of the argument in parentheses.
That’s three separate opcodes doing the work:
| Opcode | What it does |
|---|---|
LOAD_FAST | Push counter’s value onto the stack |
BINARY_OP | Compute counter + 1 |
STORE_FAST | Write the result back to counter |
The GIL can release between any of these. If two threads both execute LOAD
before either executes STORE, they both see the same starting value and one
increment is silently lost.
The old model: 100 opcodes (Python 1.0 – 3.1)
For most of Python’s history, the GIL released every 100 opcodes,
controlled by sys.getcheckinterval(). This was a round number chosen for
simplicity, not calibrated to any particular latency target.
On 1990s hardware (millions of simple operations per second), 100 opcodes may have accidentally approximated a few milliseconds. But as hardware got faster, 100 opcodes shrank to microseconds, and by the time Python 3.2 shipped in 2011, threads were fighting over the GIL far more often than intended. The coordination overhead from constant acquire/release cycles hurt performance even on single-threaded programs, since the check fired regardless of how many threads were running.
The current model: 5ms (Python 3.2+)
Python 3.2 replaced the opcode counter with a time-based mechanism,
defaulting to 5ms (sys.getswitchinterval()). A background watchdog thread sets
an eval_breaker flag every 5ms; the running thread checks that flag and yields
the GIL when it fires.
In a tight arithmetic loop, roughly 50,000–200,000 opcodes might execute in that 5ms window, wildly more than 100, which illustrates how broken the old model had become on modern hardware.
When exactly does the GIL release?
The 5ms timer doesn’t release the GIL directly. It sets the eval_breaker flag,
and the running thread releases the GIL the next time it checks that flag. Where
those checks happen has changed:
- Python 3.2–3.10:
eval_breakerwas checked at the top of every opcode dispatch loop iteration, so the GIL released after at most one more opcode. - Python 3.11+: As part of the specializing adaptive interpreter, the check
was moved to backward jumps and function calls only, a performance
optimization that avoids the overhead of checking on every single opcode.
A backward jump (
JUMP_BACKWARD) is the opcode that closes a loop. It fires once per iteration of anyfororwhileloop, when control returns to the top. Straight-line code (if/else, sequential statements) only jumps forward and never triggers a check.
The practical implication: in Python 3.11+, a straight-line sequence of
opcodes with no loop back-edge or function call will not be interrupted by the
timer. The LOAD / BINARY_OP / STORE sequence for counter += 1 contains none
of those check points, which is a significant reason why naïve race-condition
demos almost never fail with the GIL active.
Note that in a tight loop with a short body, JUMP_BACKWARD fires on every
iteration, but the GIL only actually releases when the 5ms timer has also
elapsed. The check point and the timer work together: the check point is where
the GIL can release, and the timer controls when.
What “releasing the GIL” actually means
“The GIL is released” is shorthand for the running thread relinquishing the lock so another waiting thread can acquire it and start running Python bytecode. The release is the mechanism that makes a thread switch possible; whether a switch actually happens depends on whether other Python threads are waiting and what the OS scheduler decides.
Two layers of switching coincide here but aren’t the same thing:
- GIL handoff at the interpreter level: which thread is allowed to execute Python bytecode right now.
- OS context switch at the kernel level: which thread the CPU is actually running.
A GIL release lets a different Python thread take over the interpreter, and the OS typically performs a context switch to actually put that thread on a CPU. Since Python 3.2, the GIL implementation deliberately waits for another thread to grab the lock after release, rather than letting the releaser re-take it immediately. This was added to prevent starvation on multicore machines, where the releasing thread would often win the re-acquisition race against threads waking up on other cores.
In short, “the GIL is released” means that another Python thread now has the opportunity to run, and if one is waiting, it will. Under the free-threaded build the whole mechanism is gone. Threads execute Python bytecode in parallel without any handoff, and only the OS-level context switching remains.
Cooperative vs. preemptive switching
The most familiar form of context switching is cooperative: a lock is acquired on entry to a critical section and released on exit. The programmer controls exactly where switches can occur. The downside is that a thread that never yields can starve everything else.
Preemptive switching (whether by instruction count or by time) hands that
decision to the scheduler. No thread can starve others, but switches can happen
anywhere, including places the programmer never considered. That is precisely
the source of the race in counter += 1: no one requested a switch between
LOAD and STORE, but the scheduler has no knowledge of that boundary.
The GIL is a hybrid
The GIL sits between these two models:
- Preemptive at the scheduling level: the 5ms timer fires regardless of what the code is doing.
- Cooperative at the opcode level: the running thread only actually yields at the next check point (backward jump or function call in 3.11+).
What also makes the GIL unusual is that it is a single global lock covering the entire interpreter, not a fine-grained lock around specific data. “Entering a critical section” in CPython effectively means “holding the GIL,” which every thread already does whenever it runs Python code. Preemptive scheduling then becomes: which thread next holds the single lock.
Making the race visible: forcing a context switch
Because the 5ms timer almost never fires in the ~3 opcodes of counter += 1,
demos based on counter += 1 in a tight loop rarely fail with the GIL active.
The standard fix is to split the operation manually and force a GIL release in
the middle using time.sleep(0):
# from examples/context_switch.py
import time
import constants as c
from utils import report, run_threads
counter: int = 0
def increment(iterations: int) -> None:
global counter
for _ in range(iterations):
temp = counter # LOAD
time.sleep(0) # force context switch
counter = temp + 1 # STORE (may overwrite another thread's write)
if __name__ == "__main__":
iters = 50
run_threads(increment, (iters,))
report("threaded", counter, c.NUM_THREADS * iters)
time.sleep() is a blocking call, and all blocking calls release the GIL. This
guarantees a context switch occurs between every LOAD and STORE, making
lost increments a certainty rather than a rare event, even with the GIL active.
With 8 threads and 50 iterations each, the expected result is 400. A typical run produces something in the 40–100 range.
Why this still matters with the free-threaded build
In Python’s free-threaded build (3.14t, no GIL), context_switch.py fails
without the sleep(0) for a more fundamental reason: there is no longer any
implicit mutual exclusion to accidentally rely on. The sleep(0) demo is useful
precisely because it shows the race with the GIL active, making the point that
the GIL does not protect you from race conditions; it only makes them unlikely
by serializing opcode execution.
Summary
| Topic | Key point |
|---|---|
| Opcode count | ~100–130 named; ~180–220 including adaptive variants |
| Old switch interval | Every 100 opcodes (Python 1.0–3.1) |
| Current switch interval | Every 5ms via eval_breaker flag (Python 3.2+) |
| Check point location | Every opcode (3.2–3.10); backward jumps + calls only (3.11+) |
| GIL model | Preemptive scheduling, cooperative yield points |
| Forcing a race | time.sleep(0) releases the GIL between LOAD and STORE |