The GIL and What It Really Blocks
What the global interpreter lock does and does not prevent, why threads still help for I/O, what free-threaded builds change, and how to choose between the models on evidence.
What the global interpreter lock does and does not prevent, why threads still help for I/O, what free-threaded builds change, and how to choose between the models on evidence.
You are told Python cannot do concurrency because of the GIL. So you skip threads entirely, write a script that fetches four hundred URLs one after another, and it takes eleven minutes — almost all of it spent waiting on a network that was idle the whole time. Threads would have made it forty seconds.
The GIL is real and it is narrower than its reputation. By the end of this lesson you will know exactly what it prevents, why threads help enormously for one kind of work and not at all for another, how to tell which kind you have, and what the free-threaded builds change.
The global interpreter lock is a single mutex inside CPython. A thread must hold it to execute Python bytecode, so only one thread runs Python code at a time, however many cores the machine has.
It exists because reference counting is not thread-safe. Every assignment, every function call, every append changes a reference count — and two threads incrementing the same counter without synchronisation eventually lose an update, which frees a live object and crashes the interpreter. Locking every counter individually would be correct and slow; one interpreter-wide lock is the trade CPython made.
Two consequences, and the difference between them is the whole lesson.
CPU-bound Python code gets no speedup from threads. Four threads computing checksums take as long as one, plus overhead.
I/O-bound code does. Because the lock is released whenever a thread waits.
Any operation that blocks releases the GIL before it waits and reacquires it afterwards:
network reads and writes a socket, an HTTP call
file reads and writes disk
time.sleep()
subprocess waits
lock and queue waits
much numpy, and most C extensions doing real workSo while one thread waits for a server to answer, another runs Python. Four hundred requests that each spend 1.6 seconds waiting and a few milliseconds parsing overlap almost perfectly:
from concurrent.futures import ThreadPoolExecutor
import httpx
def fetch(client, url):
response = client.get(url, timeout=10)
response.raise_for_status()
return response.json()
with httpx.Client() as client:
with ThreadPoolExecutor(max_workers=16) as pool:
results = list(pool.map(lambda u: fetch(client, u), urls))Eleven minutes becomes under a minute, on the interpreter people say cannot do concurrency. The GIL was never the constraint — the program was waiting, not computing.
Bad — threads for work that never waits.
Good — processes for the same work.
Hashing is arithmetic on bytes already in memory. Eight threads hold the same one lock in turn, so the wall-clock time matches the single-threaded version and then adds context switching — the code looks parallel, reports no error, and is slightly slower than the loop it replaced. That is the worst kind of change: it costs complexity and buys nothing, and nobody measured, because surely it should have helped.
Eight processes have eight interpreters and eight GILs, and use eight cores.
The diagnosis takes one measurement. Run the work and watch CPU usage: pinned at one core's worth means CPU-bound, and near zero means waiting. Or reason about it directly — is this program computing, or is it waiting? File reads, network calls, database queries and subprocesses are waiting. Parsing, compressing, hashing, resizing images and numeric loops are computing.
Threads, or asyncio.
A few hundred tasks — threads. No rewrite, and they work with libraries that were never made async.
Thousands — asyncio. A task costs a few kilobytes rather than a thread's stack, and switches without asking the operating system.
Processes, or a library that lets go.
On one machine — processes. Eight interpreters, eight GILs, eight cores.
Already inside a library — nothing to do. numpy
releases the GIL during array operations, so the heavy work
is on several cores while your Python code holds the lock.
That last box is the one people miss, and checking it costs a minute. Reaching for multiprocessing before finding out whether the expensive part already releases the lock is a common wasted afternoon.
The most dangerous misreading: only one thread runs at a time, therefore my code is thread-safe.
It is not. The lock is released between bytecodes, and almost nothing you write is a single bytecode.
Run that in two threads and the total is under 200,000. A thread can be suspended between the read and the write, and the update is lost — the same race that exists in any language.
Some operations are atomic — appending to a list, a single
dictionary assignment — because they complete in one bytecode.
Relying on that is fragile: the guarantee is an implementation
detail, the set of atomic operations is not documented as a
contract, and a small refactor turns one bytecode into three.
Use a lock, or use queue.Queue, which is designed for this and
removes the question.
Python 3.13 introduced an official build with no GIL, and 3.14 continues it. Reference counting is made thread-safe by other means, and threads genuinely run Python bytecode in parallel.
What changes: CPU-bound threading becomes real, so the checksum example above would use eight cores with threads.
What does not change: every race in your code is still a race, and more of them will actually happen because the threads now run at once rather than taking turns. Code that was accidentally safe because of bytecode atomicity is no longer safe. Locks matter more, not less.
The practical position today: it is a separate build, single-
threaded performance is somewhat lower, and C extensions must
opt in as compatible — so much of the scientific stack is still
catching up. Write code that would be correct either way, which
means proper locking, and check sys._is_gil_enabled() if you
need to know at runtime.
You can now tell which kind of work you have, choose threads, processes or neither on evidence, and you know that the lock never made your code thread-safe. The question to keep asking is the diagnostic one — computing or waiting — because it decides the answer before any tool does.
Next is Concurrency with Threads and Processes, which is the
practical follow-through: concurrent.futures as one interface
over both, what processes cost in pickling and startup, how to
share state without corrupting it, and the deadlocks that come
from getting either wrong.
Before you move on, take a loop that fetches things over a
network and rewrite it with a ThreadPoolExecutor. Time both.
Then do the same with a CPU-bound loop and time that too. Two
measurements, ten minutes, and the GIL stops being folklore.
WHAT IT IS
one mutex; only one thread executes Python bytecode at a time
it exists because reference counting is not thread-safe
WHAT IT BLOCKS
parallel execution of PYTHON code yes
concurrency while WAITING no
the lock is released around:
network, disk, time.sleep, subprocess, locks/queues,
numpy and most C extensions doing real work
DIAGNOSING
is the program COMPUTING or WAITING?
waiting: network, files, database, subprocess
computing: parse, compress, hash, resize, numeric loops
or watch CPU: pinned at one core = CPU-bound
CHOOSING
waiting, hundreds of tasks -> threads
waiting, thousands -> asyncio
computing -> processes
computing inside numpy/a C ext -> already parallel; check first
threads on CPU-bound work: no speedup, plus overhead,
and no error to tell you
WHAT IT DOES NOT GIVE YOU
thread safety. `counter += 1` is read, add, write
the lock is released BETWEEN bytecodes
some single-bytecode ops are atomic - do not rely on it
use threading.Lock, or queue.Queue
FREE-THREADED BUILDS (3.13+)
CPU-bound threading becomes real
every race is still a race, and now more of them happen
separate build; C extensions must opt in
sys._is_gil_enabled()from concurrent.futures import ThreadPoolExecutor
def checksum(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
with ThreadPoolExecutor(max_workers=8) as pool:
digests = list(pool.map(checksum, paths))from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor(max_workers=8) as pool:
digests = list(pool.map(checksum, paths))counter = 0
def increment():
global counter
for _ in range(100_000):
counter += 1 # read, add, write - three stepsimport threading
lock = threading.Lock()
def increment():
global counter
for _ in range(100_000):
with lock:
counter += 1