The GIL and Threading
The Global Interpreter Lock
Section titled “The Global Interpreter Lock”CPython (the standard Python interpreter) contains a mutex called the Global Interpreter Lock (GIL). The GIL ensures that only one thread executes Python bytecode at any given moment — even on a machine with many CPU cores.
The GIL exists for a practical reason: CPython’s memory management (reference counting) is not thread-safe. Rather than adding fine-grained locks to every object, the CPython developers took the simpler approach of a single global lock.
Consequences:
- CPU-bound Python code running in multiple threads does not run in parallel — threads take turns.
- I/O-bound threads do benefit from threading because the GIL is released while a thread waits for an OS I/O call to complete.
Check whether the interpreter you are running has the GIL at all with sys._is_gil_enabled():
import sys
print(sys._is_gil_enabled()) # True on the default python3.14 buildWhat the GIL does and does not protect
Section titled “What the GIL does and does not protect”The GIL guarantees memory safety: no two threads can corrupt CPython’s internal object
structures by writing to them at the same instant. It does not guarantee that a sequence of
Python statements executes as one atomic unit. A statement like counter += 1 compiles to several
bytecode instructions, not one:
import dis
def bump(): global counter counter += 1
dis.dis(bump) 6 RESUME 0
8 LOAD_GLOBAL 0 (counter) LOAD_SMALL_INT 1 BINARY_OP 13 (+=) STORE_GLOBAL 0 (counter) LOAD_CONST 1 (None) RETURN_VALUEFour opcodes: read counter, push 1, add, write counter back. In principle the GIL could switch
to another thread between any two of these, which is the textbook reason += on shared state
“is not atomic” and “needs a lock.” What actually happens in practice on 3.14 is more interesting —
measured below.
Measuring the += race honestly
Section titled “Measuring the += race honestly”The folklore demo is 8 threads each doing counter += 1 two million times with no lock, expecting a
lower-than-expected final count. Run that exact experiment on python3.14, including with the GIL’s
switch interval turned all the way down to force far more context switches than default:
import sysimport threading
sys.setswitchinterval(0.0000001) # force much more frequent GIL switches than the 5ms default
counter = 0
def increment(n: int) -> None: global counter for _ in range(n): counter += 1
N_PER_THREAD = 500_000NUM_THREADS = 8threads = [threading.Thread(target=increment, args=(N_PER_THREAD,)) for _ in range(NUM_THREADS)]for t in threads: t.start()for t in threads: t.join()
print(f"expected: {N_PER_THREAD * NUM_THREADS}")print(f"actual: {counter}")expected: 4000000actual: 4000000Zero lost updates — even under an artificially extreme switch interval. The reason is that CPython’s
“eval breaker” (the check for “should I let another thread run now?”) only fires at specific safe
points in the bytecode dispatch loop — chiefly loop back-edges and calls — not between every single
instruction. A bare counter += 1 inside a for loop has no such safe point between its four
opcodes, only at the top of the next iteration. So this particular pattern turns out to be
effectively atomic in CPython today, purely as an implementation detail of where the interpreter
checks for a switch — not because the language guarantees it.
That distinction matters: change the pattern so a real suspension point sits between the read and the write — a blocking call, for instance — and the race reappears immediately:
import threadingimport time
class Account: def __init__(self, balance: int) -> None: self.balance = balance
def withdraw_unsafe(acct: Account, amount: int) -> None: balance = acct.balance # read time.sleep(0.01) # GIL released here — another thread can run acct.balance = balance - amount # write, based on a now-stale read
acct = Account(balance=100)threads = [threading.Thread(target=withdraw_unsafe, args=(acct, 80)) for _ in range(2)]for t in threads: t.start()for t in threads: t.join()
print(acct.balance)20Both threads read balance=100 before either wrote — a lost update, reliably reproduced, because
time.sleep genuinely releases the GIL mid-sequence. The lesson is not “+= is safe.” It is:
the GIL prevents memory corruption, not logical races — and whether a specific check-then-act
sequence races in practice depends on whether anything between the check and the act can yield the
GIL, which is an implementation detail you should never rely on. Always protect a real
read-modify-write with a Lock; do not trust a demo that “didn’t lose any updates” as proof a
pattern is safe.
C extensions can release the GIL explicitly
Section titled “C extensions can release the GIL explicitly”Code implemented in C (much of the standard library’s performance-sensitive parts, plus most
compiled third-party libraries) can explicitly release the GIL around a long-running operation that
does not touch Python objects — hashlib, zlib, and NumPy’s array operations all do this. That
means threads calling into these functions can run in true parallel, unlike pure-Python loops:
import hashlibimport threadingimport time
DATA = b"x" * 50_000_000
def hash_chunk() -> None: hashlib.sha256(DATA).hexdigest() # implemented in C; releases the GIL during the hashing loop
start = time.perf_counter()hash_chunk(); hash_chunk()single = time.perf_counter() - start
start = time.perf_counter()threads = [threading.Thread(target=hash_chunk) for _ in range(2)]for t in threads: t.start()for t in threads: t.join()threaded = time.perf_counter() - start
print(f"one thread, two calls: {single:.2f}s")print(f"two threads, one each: {threaded:.2f}s")Measured on this machine: 0.04s for two sequential calls versus 0.02s running them on two
threads — roughly 2x, because hashlib.sha256 drops the GIL while it works. The exact same loop
written in pure Python (like the count_down example above) would show no such speedup.
CPU-bound threads: no speedup
Section titled “CPU-bound threads: no speedup”import threadingimport time
def count_down(n: int) -> None: while n > 0: n -= 1
N = 50_000_000
# Single threadstart = time.perf_counter()count_down(N)single = time.perf_counter() - start
# Two threads — same total work split across threadsstart = time.perf_counter()t1 = threading.Thread(target=count_down, args=(N // 2,))t2 = threading.Thread(target=count_down, args=(N // 2,))t1.start(); t2.start()t1.join(); t2.join()threaded = time.perf_counter() - start
print(f"Single thread: {single:.2f}s")print(f"Two threads: {threaded:.2f}s")# Measured three times on this machine (python3.14): 0.51s vs 0.52s, 0.51s vs# 0.53s, 0.51s vs 0.51s — two threads are not faster (same total work, same# time) and can be marginally slower from GIL-switch and context-switch# overhead. Numbers are machine-dependent.The free-threaded build, measured
Section titled “The free-threaded build, measured”The claim “free-threaded Python lets CPU-bound threads run in parallel” is easy to state and easy to
get wrong in the details, so here is the exact same count_down benchmark run on both the default
GIL build and python3.14t, back to back on the same machine:
$ python3.14 cpu_bench.py # default build, GIL enabledgil_enabled=Truesingle thread: 0.43stwo threads: 0.41sspeedup: 1.04x
$ uv run --python 3.14t python cpu_bench.py # free-threaded buildgil_enabled=Falsesingle thread: 0.42stwo threads: 0.21sspeedup: 1.96xOn the default build, two threads bring essentially no speedup (1.04x — noise-level, matching the
“two threads is not faster” result above). On the free-threaded build, sys._is_gil_enabled()
correctly reports False, and two threads come close to the ideal 2x for this embarrassingly
parallel workload. Single-threaded performance was statistically indistinguishable between the two
builds on this measurement (0.43s vs 0.42s) — the commonly cited “5–10% single-thread overhead” for
free-threaded builds did not show up here; measure your own workload before assuming either way, and
check whether your C-extension dependencies have opted into free-threading support at all before
switching a real project.
I/O-bound threads: real concurrency
Section titled “I/O-bound threads: real concurrency”When threads block on I/O, the GIL is released, allowing other threads to run Python code. This is why threading is still useful for I/O-bound tasks like HTTP requests or file reads.
import threadingimport time
def simulate_io(label: str, delay: float) -> None: time.sleep(delay) # releases the GIL — other threads run freely print(f"{label} complete after {delay}s")
# Sequential: ~0.6 s total# Threaded: ~0.2 s total (all three sleep concurrently)threads = [ threading.Thread(target=simulate_io, args=(f"task-{i}", 0.2)) for i in range(3)]for t in threads: t.start()for t in threads: t.join()threading.Thread — basics
Section titled “threading.Thread — basics”import threading
def worker(name: str, count: int) -> None: for i in range(count): print(f"[{name}] step {i}")
t = threading.Thread(target=worker, args=("background", 3))t.start()print("main thread continues here")t.join() # wait for the thread to finishprint("background thread done")t.join() blocks the calling thread until t finishes.
Without join(), the main thread may exit while the background thread is still running.
Daemon threads
Section titled “Daemon threads”A daemon thread does not keep the process alive — when every non-daemon thread (including
main) has finished, Python exits, killing any daemon threads mid-execution without running their
finally blocks. Use daemon threads for background work whose loss on exit is acceptable (a
heartbeat, a cache-warming loop); never for anything that must clean up after itself:
import threadingimport time
def background_ticker(stop: threading.Event) -> None: while not stop.is_set(): time.sleep(0.01)
stop = threading.Event()t = threading.Thread(target=background_ticker, args=(stop,), daemon=True)t.start()print(f"daemon thread running: {t.is_alive()}, daemon={t.daemon}")# no t.join() here -- a daemon thread is killed automatically when the main# thread exits; a non-daemon thread in its place would keep the process alive# forever, since background_ticker only stops when `stop` is setstop.set()Thread safety with threading.Lock
Section titled “Thread safety with threading.Lock”Shared mutable state requires explicit synchronization when a real suspension point (I/O, a
blocking call, or a section long enough for the OS scheduler to preempt it) can land between a read
and the write that depends on it. A threading.Lock ensures that only one thread executes the
locked section at a time:
import threading
counter: int = 0lock = threading.Lock()
def increment(n: int) -> None: global counter for _ in range(n): with lock: counter += 1
threads = [threading.Thread(target=increment, args=(100_000,)) for _ in range(5)]for t in threads: t.start()for t in threads: t.join()
print(counter) # reliably 500_000Reach for Lock whenever a race condition is possible in principle — a check-then-act, a
read-modify-write spanning any call that might block — rather than relying on whether a particular
run happened to show corruption. As measured above, the absence of a data race on one run is not
evidence of correctness.
queue.Queue vs a plain list for cross-thread data
Section titled “queue.Queue vs a plain list for cross-thread data”A plain list is not safe to share between threads for anything beyond what the GIL happens to make
atomic (append/pop on a list are individually safe, but a check-then-act like “if not empty,
pop” is not). queue.Queue is purpose-built for cross-thread handoff: put/get are internally
locked and get blocks until an item is available, which a list’s pop(0) cannot do without a
manual sleep-and-retry loop:
import queueimport threading
q: queue.Queue = queue.Queue()
def producer() -> None: for i in range(5): q.put(i) q.put(None) # sentinel
def consumer(out: list) -> None: while True: item = q.get() # blocks until an item is available -- no busy-waiting if item is None: break out.append(item)
out: list = []p = threading.Thread(target=producer)c = threading.Thread(target=consumer, args=(out,))p.start(); c.start()p.join(); c.join()print(out) # [0, 1, 2, 3, 4]ThreadPoolExecutor — the high-level API
Section titled “ThreadPoolExecutor — the high-level API”concurrent.futures.ThreadPoolExecutor manages a pool of threads and returns Future objects.
map and submit/as_completed differ in one important way: map always yields results in
input order, while as_completed yields them in completion order:
from concurrent.futures import ThreadPoolExecutor, as_completedimport time
def work(label: str, delay: float) -> str: time.sleep(delay) return f"{label} done"
with ThreadPoolExecutor(max_workers=4) as pool: print(list(pool.map(work, ["A", "B", "C"], [0.06, 0.02, 0.04]))) # ['A done', 'B done', 'C done'] -- input order, even though B and C finish first
with ThreadPoolExecutor(max_workers=4) as pool: futures = {pool.submit(work, label, delay): label for label, delay in [("A", 0.06), ("B", 0.02), ("C", 0.04)]} print([f.result() for f in as_completed(futures)]) # ['B done', 'C done', 'A done'] -- completion orderfuture.result() re-raises whatever exception the worker function raised, at the call site — the
same as if you had called the function directly, minus a traceback frame:
from concurrent.futures import ThreadPoolExecutor
def boom() -> int: raise ValueError("worker failed")
with ThreadPoolExecutor(max_workers=1) as pool: fut = pool.submit(boom) try: fut.result() except ValueError as e: print(f"re-raised: {e!r}") # re-raised: ValueError('worker failed')Bridging threads and asyncio: run_in_executor
Section titled “Bridging threads and asyncio: run_in_executor”When you need to call a blocking library from an async context without blocking the event loop:
import asynciofrom concurrent.futures import ThreadPoolExecutorimport time
def blocking_call(x: int) -> int: time.sleep(0.1) # a blocking library — e.g. psycopg2, boto3 (sync) return x * x
async def main() -> None: loop = asyncio.get_running_loop() with ThreadPoolExecutor(max_workers=4) as pool: results = await asyncio.gather( *[loop.run_in_executor(pool, blocking_call, i) for i in range(5)] ) print(results)
asyncio.run(main())asyncio.to_thread, covered in the Async / Await lesson, is a thin wrapper over exactly this call
with the default executor.
Depth demo: ThreadPoolExecutor, daemon threads, and Queue
Section titled “Depth demo: ThreadPoolExecutor, daemon threads, and Queue”Timing-based claims (CPU-bound speedup, the free-threaded benchmark) require real OS-level parallel
threads and cannot be trusted inside the browser sandbox, so those stay as fenced examples above to
run locally. The correctness-only demos below — map vs submit/as_completed ordering,
future.result() re-raising, daemon-thread flags, and queue.Queue handoff — do not depend on real
parallelism and run the same way in Pyodide as they do locally:
from concurrent.futures import ThreadPoolExecutor, as_completedimport queueimport threadingimport time
def work(label: str, delay: float) -> str: time.sleep(delay) return f"{label} done"
with ThreadPoolExecutor(max_workers=4) as pool: print("map (input order):", list(pool.map(work, ["A", "B", "C"], [0.06, 0.02, 0.04])))
with ThreadPoolExecutor(max_workers=4) as pool: futures = {pool.submit(work, label, delay): label for label, delay in [("A", 0.06), ("B", 0.02), ("C", 0.04)]} order = [fut.result() for fut in as_completed(futures)] print("submit + as_completed (completion order):", order)
def boom() -> int: raise ValueError("worker failed")
with ThreadPoolExecutor(max_workers=1) as pool: fut = pool.submit(boom) try: fut.result() except ValueError as e: print(f"future.result() re-raises: {e!r}")
stop = threading.Event()t = threading.Thread(target=lambda: stop.wait(), daemon=True)t.start()print(f"daemon thread: alive={t.is_alive()}, daemon={t.daemon}")
q: queue.Queue = queue.Queue()
def producer() -> None: for i in range(5): q.put(i) q.put(None)
def consumer(out: list) -> None: while True: item = q.get() if item is None: break out.append(item)
out: list = []p = threading.Thread(target=producer)c = threading.Thread(target=consumer, args=(out,))p.start(); c.start()p.join(); c.join()print(f"queue.Queue producer/consumer result: {out}")stop.set()Loading Python runtime (first run only)…
When to use threads vs asyncio
Section titled “When to use threads vs asyncio”| Criterion | Use asyncio | Use threading |
|---|---|---|
| I/O-bound, async-native libs | Yes | No |
| I/O-bound, blocking libs (boto3, psycopg2) | run_in_executor | Yes |
| CPU-bound work | No | No — use multiprocessing or free-threaded 3.14t |
| Need structured cancellation | Yes (TaskGroup) | Harder |
| Legacy synchronous codebase | No | Yes |
Mental Model
Section titled “Mental Model”Picture the GIL as a single relay baton in a room full of runners (threads). Only whoever holds the
baton may touch the shared Python objects on the table. A runner drops the baton voluntarily when it
calls something that waits — I/O, time.sleep, a C function that releases it — and CPython also
forces a handoff periodically so no one thread can hog the baton forever during pure computation.
The baton itself only prevents two runners grabbing an object at once (memory safety); it says
nothing about whether the sequence of moves one runner made while holding it, across several
handoffs, still adds up to a correct outcome.
flowchart LR T1["Thread 1 holds GIL"] -->|"time.sleep() releases it"| T2["Thread 2 acquires GIL"] T2 -->|"finishes I/O wait, re-acquires"| T1b["Thread 1 resumes"] T2 -.->|"pure computation: no release until eval-breaker checkpoint"| T2
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| The GIL | Simple, safe reference counting; no per-object locks | CPU-bound threads never run Python bytecode in parallel |
threading for I/O | Simple shared-memory model, no serialization | Still needs Lock around any check-then-act that spans a blocking call |
| Free-threaded 3.14t | Real parallel speedup for CPU-bound pure-Python threads | Separate binary; C-extension ecosystem support still catching up |
ThreadPoolExecutor | Clean high-level API, integrates with asyncio | map and submit/as_completed have different ordering guarantees — easy to mix up |
| Daemon threads | Process exits promptly, no dangling background loop | No cleanup guarantee — finally blocks can be skipped entirely |
Common Misconceptions
Section titled “Common Misconceptions”- “
+=on shared state always races without a lock.” Measured above: a tight loop ofcounter += 1showed zero lost updates even under an artificially aggressive switch interval, because CPython’s eval-breaker only checks at loop/call boundaries. That does not make it safe — it means the absence of an observed race is not proof of correctness. Any read-modify-write with a real suspension point in between (a blocking call, I/O) does race reliably. - “The GIL means Python code is single-threaded-safe by default.” It prevents memory corruption, not logic errors. A check-then-act sequence is still a race if anything between the check and the act can yield control.
- “Free-threaded Python is a compiler flag.” It is a separate build of the interpreter
(
python3.14t) with a different ABI; C extensions must explicitly declare support for it. - “
ThreadPoolExecutor.mapandas_completedbehave the same way.”mapalways preserves input order regardless of which call finishes first;as_completedyields whichever future finishes first, which can differ from submission order. - “A daemon thread is just a normal thread with a boolean flag for style.” It changes real behavior: the process will not wait for it, and it can be killed mid-statement with no cleanup at process exit.
Reading AI-generated code
Section titled “Reading AI-generated code”Before trusting generated threading code, check for these:
- Does a shared counter or accumulator get updated with
+=(or any read-modify-write) from multiple threads with noLock, justified by “it’s just an integer”? The safety of that depends on implementation details that can change; aLockcosts almost nothing here. - Is there a check-then-act (
if queue: item = queue.pop(0),if not path.exists(): create(path)) shared across threads with a blocking call anywhere in between? - Is a background thread created with
daemon=Truewhen it actually needs to finish cleanup work before the process exits? - Does code call
ThreadPoolExecutor.submitin a loop and then.result()each future immediately in submission order — defeating the entire point of submitting them concurrently, and equivalent to just callingmap? - Is
multiprocessing/ProcessPoolExecutorbeing fed alambdaor a locally defined closure? See the Multiprocessing lesson — this fails with a real pickling error, not a warning.
🐛 Spot the bug
class Account: def __init__(self, balance: int) -> None: self.balance = balance
def withdraw(acct: Account, amount: int) -> None: if acct.balance >= amount: time.sleep(0.01) # e.g. a "logging" or "audit" call before committing acct.balance -= amount
acct = Account(balance=100)threads = [threading.Thread(target=withdraw, args=(acct, 80)) for _ in range(2)]for t in threads: t.start()for t in threads: t.join()
print(acct.balance)Show the answer
This looks like a straightforward balance check before a withdrawal, and a quick read might assume
the GIL keeps it safe since there’s “only one thread running Python at a time.” But time.sleep
between the check and the write releases the GIL, letting the second thread’s if acct.balance >= amount run against the still-unmodified balance. Both checks pass, both withdrawals apply, and
acct.balance ends up at 20 instead of the correct -60 rejection or a serialized 20/reject
outcome — either way, both threads believed they were entitled to withdraw from the same 100. The
fix is a threading.Lock held for the entire check-then-act sequence, not just the final
subtraction.