The one lock that shapes all of Python threading. Understand it and every threading decision you make afterwards becomes obvious.
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.
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.
It's tempting to see the GIL as pure legacy baggage, but it bought CPython three concrete things:
++/-- under one big lock. That
keeps the common (single-threaded) case cheap.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:
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
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.
| Situation | GIL? | Why |
|---|---|---|
Running pure-Python bytecode (loops, math on int/float) | Held | Executing bytecode requires the lock |
| Blocking network / socket I/O | Released | Thread is parked in the OS kernel, not the interpreter |
| Blocking disk / file read/write | Released | Same โ kernel wait |
time.sleep(n) | Released | Explicitly drops the GIL for the whole sleep |
Inside a C extension wrapped in Py_BEGIN_ALLOW_THREADS (many NumPy ops, hashlib, compression) | Released | Extension told the interpreter it's safe to let others run |
| C extension not releasing it (touching Python objects) | Held | It may be mutating refcounts |
Waiting to acquire a threading.Lock | Released | Blocking on a lock parks the thread |
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.
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")
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.
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:
multiprocessing or
ProcessPoolExecutor. Each process has its own interpreter and its
own GIL, so they run bytecode truly in parallel. The cost is that memory isn't shared:
arguments and results are pickled across process boundaries, and startup is
heavier. This is the standard answer for CPU-bound Python.
python3.13t). In it, CPU-bound threads scale across cores like other languages.
It's officially experimental and is maturing through 3.14 and beyond; expect
single-threaded slowdowns and C-extension compatibility gaps in the meantime.
concurrent.interpreters) arrives in 3.14.
It's lighter than processes but still isolates most state.
# 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
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.
| Concept | Takeaway |
|---|---|
| What it is | One CPython mutex; only one thread runs bytecode at a time |
| What it protects | Interpreter state, chiefly reference counts |
| Why it exists | Fast simple refcounting + easy C extensions |
| Released during | I/O, time.sleep, and GIL-releasing C code (NumPy) |
| Switch interval | sys.getswitchinterval() โ 5ms default; fairness knob |
| I/O-bound threads | Help a lot โ they overlap the waiting |
| CPU-bound threads | Same-or-slower โ use processes, C, or free-threaded CPython |
| Escapes | processes, NumPy/C, free-threaded (PEP 703), subinterpreters (PEP 734) |