Frontend Labs
⋯

27. The GIL, threads, and free-threaded Python

27.1 What the GIL is

The Global Interpreter Lock is a mutex inside CPython. A thread must hold it to execute Python bytecode. It exists because reference counts are modified on every operation, and making each increment atomic was too slow in the 1990s; one big lock made the interpreter and C extensions simple and fast for single-threaded code.

Consequences:

  • CPU-bound Python code does not run in parallel on threads. Two threads computing in pure Python take turns; the total time is the same as (or worse than) one thread.
  • I/O releases the GIL. A thread waiting on a socket, a file, sleep, or a database releases the GIL, so other threads run. Threads are fine for I/O-bound work.
  • C extensions can release it. NumPy, PyTorch, hashlib, zlib, and regex engines in some libraries release the GIL during long native operations, so threads calling them can use several cores.
import hashlib, threading, time

def cpu(n):                         # pure Python: holds the GIL
    s = 0
    for i in range(n):
        s += i * i

def native(data):                   # hashlib releases the GIL for large inputs
    for _ in range(20):
        hashlib.sha256(data).digest()

def timeit(fn, args, threads):
    ts = [threading.Thread(target=fn, args=args) for _ in range(threads)]
    t = time.perf_counter()
    for th in ts: th.start()
    for th in ts: th.join()
    return time.perf_counter() - t

data = bytes(50_000_000)
for name, fn, args in [("pure python", cpu, (3_000_000,)), ("hashlib", native, (data,))]:
    one, four = timeit(fn, args, 1), timeit(fn, args, 4)
    print(f"{name:12} 1 thread {one:.2f}s  4 threads {four:.2f}s  ({4 * one / four:.1f}x throughput)")

On a standard (GIL) build, the pure-Python line shows about 1× throughput with 4 threads, and the hashlib line shows close to 4×.

27.2 The GIL does not make your code thread-safe

The GIL protects CPython's internals, not your invariants. A thread can be switched out between any two bytecode instructions, and counter += 1 is several instructions (load, add, store):

import threading

counter = 0
def work():
    global counter
    for _ in range(200_000):
        counter += 1          # read-modify-write: not atomic

ts = [threading.Thread(target=work) for _ in range(8)]
for t in ts: t.start()
for t in ts: t.join()
print(counter)                # often less than 1_600_000; a lost-update race
import threading

lock = threading.Lock()
counter = 0
def work():
    global counter
    for _ in range(200_000):
        with lock:
            counter += 1

This is the race from OS Ch 17, and the lock is the one from OS Ch 18. The better design avoids shared mutable state entirely: give each thread its own data and combine results at the end, or pass messages through a queue.Queue.

27.3 Threads for I/O: ThreadPoolExecutor

import time
from concurrent.futures import ThreadPoolExecutor, as_completed

def fetch(url: str) -> tuple[str, float]:
    time.sleep(0.5)                       # stand-in for a network call (releases the GIL)
    return url, 0.5

urls = [f"https://svc{i}.internal/health" for i in range(20)]
t = time.perf_counter()
with ThreadPoolExecutor(max_workers=10) as pool:
    futures = [pool.submit(fetch, u) for u in urls]
    for fut in as_completed(futures):
        url, secs = fut.result()          # re-raises any exception from the worker
print(f"20 calls in {time.perf_counter() - t:.1f}s")   # ~1.0s, not 10s

pool.map(fn, items) is the simpler form when you want results in order. Ten to a few hundred threads is fine for I/O; for tens of thousands of concurrent connections, use asyncio (Ch 29).

27.4 Free-threaded Python (3.13t, supported in 3.14)

PEP 703 added a build of CPython without the GIL, installed as python3.13t / python3.14t. It uses biased and deferred reference counting and per-object locks so that pure Python threads run truly in parallel. In 3.13 it was experimental; in 3.14 it is officially supported (PEP 779), with single-threaded overhead down to roughly 5–10%.

uv python install 3.14t
uv run --python 3.14t python -c "import sys; print(sys._is_gil_enabled())"   # False
uv run --python 3.14t python gil_demo.py    # the pure-python line now scales with threads

What to know before you rely on it:

  • C extensions must declare support. Importing one that does not re-enables the GIL (with a warning). NumPy, and recent versions of many scientific packages, ship free-threaded wheels; check each dependency.
  • Your code's races become real. Code that was "accidentally safe" because the GIL serialized it may now corrupt shared state. The lock in §27.2 is mandatory, not optional.
  • It is the future direction, but in 2026 most production ML stacks still run on the default build, use processes for CPU parallelism in Python (Ch 28), and let native libraries use the cores (§27.1).

Generated from real-life-python-guide.md · 8 Oct 2026 16:36