๐Ÿ”‘ The GIL, Deeply

The one lock that shapes all of Python threading. Understand it and every threading decision you make afterwards becomes obvious.

What the GIL Actually Is

The Global Interpreter Lock is a single mutex inside the CPython interpreter. To run any Python bytecode, a thread must first hold that mutex. Because there is exactly one of them per interpreter, only one thread executes Python bytecode at any instant โ€” even on a 32-core machine.

Key Insight: The GIL is not part of the Python language. It is an implementation detail of CPython (the reference interpreter you almost certainly run). Jython and IronPython never had one. The language spec says nothing about it.

What does it protect? Chiefly CPython's reference counts. Every object carries a counter of how many references point at it; when it hits zero the object is freed. That counter is touched constantly โ€” on nearly every assignment, function call, and attribute access. If two threads incremented and decremented the same refcount without coordination, counts would corrupt, objects would be freed while still in use, and the interpreter would crash. The GIL makes each bytecode step's refcount bookkeeping safe by fiat.

Why It Exists (It's a Feature, Not Just a Wart)

It's tempting to see the GIL as pure legacy baggage, but it bought CPython three concrete things:

The trade: single-threaded and I/O-bound code got faster and simpler; CPU-bound multithreading got sacrificed. For decades that was the right call, because most Python is glue and I/O. It only hurts when you try to use threads to burn CPU in pure Python.

Threads Take Turns Holding It

A running thread doesn't keep the GIL forever. It releases it periodically so other runnable threads get a turn โ€” nobody starves. Conceptually, execution is a relay race where the baton is the GIL:

sequenceDiagram participant T1 as Thread 1 participant GIL as The GIL participant T2 as Thread 2 T1->>GIL: acquire Note over T1: runs bytecode T1->>GIL: release (switch interval / I/O) GIL->>T2: hand off Note over T2: runs bytecode T2->>GIL: release GIL->>T1: hand off Note over T1,T2: Only ONE holds it at a time.
They interleave, never truly parallel.

The interpreter decides when to invite a switch using the switch interval. You can inspect and set it:

import sys

sys.getswitchinterval()   # 0.005  โ†’ 5 milliseconds (default)

sys.setswitchinterval(0.001)   # ask for switches every ~1ms
What the interval really means: it is not "switch every 5ms of wall clock" nor "every N bytecodes". After the interval elapses, the interpreter requests that the current thread drop the GIL at the next safe point so another thread can grab it. It is a fairness/latency knob, not a performance knob โ€” lowering it means more responsive switching but more overhead from the switching itself.

When the GIL Is Released โ€” the Whole Point

Here is the fact that makes threading useful at all: a thread releases the GIL while it waits on something outside the interpreter. During that wait, other Python threads run. So overlapping waiting is exactly what threads are good at.

SituationGIL?Why
Running pure-Python bytecode (loops, math on int/float)HeldExecuting bytecode requires the lock
Blocking network / socket I/OReleasedThread is parked in the OS kernel, not the interpreter
Blocking disk / file read/writeReleasedSame โ€” kernel wait
time.sleep(n)ReleasedExplicitly drops the GIL for the whole sleep
Inside a C extension wrapped in Py_BEGIN_ALLOW_THREADS (many NumPy ops, hashlib, compression)ReleasedExtension told the interpreter it's safe to let others run
C extension not releasing it (touching Python objects)HeldIt may be mutating refcounts
Waiting to acquire a threading.LockReleasedBlocking on a lock parks the thread
The one-line rule: if a thread is waiting (I/O, sleep, a well-behaved C library), the GIL is free and other threads make progress. If a thread is computing in pure Python, it holds the GIL and everyone else stalls.

Note that Py_BEGIN_ALLOW_THREADS is why NumPy can give you real parallelism: a big np.dot releases the GIL and crunches numbers in C (often across multiple BLAS threads) while your other Python threads keep running.

The Consequence: CPU-Bound Threads Don't Speed Up

Because pure-Python CPU work holds the GIL the entire time, two CPU-bound threads cannot run their bytecode at once. They just take turns. Best case you get roughly the sequential time; worst case you get slower, because now you also pay for GIL hand-offs and cache effects.

import time, threading

def burn(n):
    # pure-Python CPU work โ€” holds the GIL the whole time
    x = 0
    for _ in range(n):
        x += 1
    return x

N = 40_000_000

# --- Sequential ---
t0 = time.perf_counter()
burn(N); burn(N)
print("sequential:", round(time.perf_counter() - t0, 2), "s")

# --- Two threads ---
t0 = time.perf_counter()
ts = [threading.Thread(target=burn, args=(N,)) for _ in range(2)]
for t in ts: t.start()
for t in ts: t.join()
print("2 threads: ", round(time.perf_counter() - t0, 2), "s")
Typical result on a normal multi-core machine (GIL build): the two versions take about the same wall-clock time โ€” and the threaded version is often a touch slower because of GIL contention overhead. Adding threads bought you nothing. This is the classic Python surprise.
Contrast with I/O: replace burn with time.sleep(1) and two threads finish in ~1s instead of ~2s โ€” because sleeping releases the GIL. Same threading code, opposite outcome, purely because of whether the work holds the lock.

Ways Around the GIL

If you genuinely need CPU parallelism in Python, you don't fight the GIL โ€” you sidestep it. Four approaches, roughly in order of how mainstream they are today:

# CPU-bound โ†’ separate processes = real parallelism, one GIL each
from concurrent.futures import ProcessPoolExecutor

def burn(n):
    x = 0
    for _ in range(n):
        x += 1
    return x

with ProcessPoolExecutor() as pool:
    results = list(pool.map(burn, [40_000_000] * 4))
# on a 4-core box this is ~4x faster than doing it in threads
Checking your build: on a free-threaded interpreter, sys._is_gil_enabled() returns False (Python 3.13+). On a normal build the attribute exists but reports True. It's the quickest way to know which world you're in.

Key Takeaways

ConceptTakeaway
What it isOne CPython mutex; only one thread runs bytecode at a time
What it protectsInterpreter state, chiefly reference counts
Why it existsFast simple refcounting + easy C extensions
Released duringI/O, time.sleep, and GIL-releasing C code (NumPy)
Switch intervalsys.getswitchinterval() โ€” 5ms default; fairness knob
I/O-bound threadsHelp a lot โ€” they overlap the waiting
CPU-bound threadsSame-or-slower โ€” use processes, C, or free-threaded CPython
Escapesprocesses, NumPy/C, free-threaded (PEP 703), subinterpreters (PEP 734)
The whole guide in one sentence: the GIL means Python threads are for overlapping waiting, not for parallel computing โ€” and when you need parallel computing, you reach for processes (next page).