Skip to content

The GIL and Threading

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 build

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_VALUE

Four 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.

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 sys
import 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_000
NUM_THREADS = 8
threads = [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: 4000000
actual: 4000000

Zero 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 threading
import 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)
20

Both 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 hashlib
import threading
import 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.

import threading
import time
def count_down(n: int) -> None:
while n > 0:
n -= 1
N = 50_000_000
# Single thread
start = time.perf_counter()
count_down(N)
single = time.perf_counter() - start
# Two threads — same total work split across threads
start = 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 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:

Terminal window
$ python3.14 cpu_bench.py # default build, GIL enabled
gil_enabled=True
single thread: 0.43s
two threads: 0.41s
speedup: 1.04x
$ uv run --python 3.14t python cpu_bench.py # free-threaded build
gil_enabled=False
single thread: 0.42s
two threads: 0.21s
speedup: 1.96x

On 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.

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 threading
import 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()
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 finish
print("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.

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 threading
import 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 set
stop.set()

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 = 0
lock = 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_000

Reach 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 queue
import 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]

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_completed
import 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 order

future.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 asyncio
from concurrent.futures import ThreadPoolExecutor
import 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_completed
import queue
import threading
import 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()
CriterionUse asyncioUse threading
I/O-bound, async-native libsYesNo
I/O-bound, blocking libs (boto3, psycopg2)run_in_executorYes
CPU-bound workNoNo — use multiprocessing or free-threaded 3.14t
Need structured cancellationYes (TaskGroup)Harder
Legacy synchronous codebaseNoYes

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
The GIL changes hands at blocking calls and periodic checkpoints — not between every bytecode
What you getBenefitCost
The GILSimple, safe reference counting; no per-object locksCPU-bound threads never run Python bytecode in parallel
threading for I/OSimple shared-memory model, no serializationStill needs Lock around any check-then-act that spans a blocking call
Free-threaded 3.14tReal parallel speedup for CPU-bound pure-Python threadsSeparate binary; C-extension ecosystem support still catching up
ThreadPoolExecutorClean high-level API, integrates with asynciomap and submit/as_completed have different ordering guarantees — easy to mix up
Daemon threadsProcess exits promptly, no dangling background loopNo cleanup guarantee — finally blocks can be skipped entirely
  • “+= on shared state always races without a lock.” Measured above: a tight loop of counter += 1 showed 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.map and as_completed behave the same way.” map always preserves input order regardless of which call finishes first; as_completed yields 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.

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 no Lock, justified by “it’s just an integer”? The safety of that depends on implementation details that can change; a Lock costs 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=True when it actually needs to finish cleanup work before the process exits?
  • Does code call ThreadPoolExecutor.submit in a loop and then .result() each future immediately in submission order — defeating the entire point of submitting them concurrently, and equivalent to just calling map?
  • Is multiprocessing/ProcessPoolExecutor being fed a lambda or 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.

Why does adding more threads NOT speed up CPU-bound Python code in CPython?
When does a Python thread release the GIL?
Run on python3.14 (GIL enabled), even with the GIL switch interval set extremely low, what was actually measured?
import threading

counter = 0

def increment(n):
    global counter
    for _ in range(n):
        counter += 1

threads = [threading.Thread(target=increment, args=(2_000_000,)) for _ in range(8)]
for t in threads: t.start()
for t in threads: t.join()

print(counter)
What does this print, and why -- given the tight += loop above showed no lost updates?
class Account:
    def __init__(self, balance):
        self.balance = balance

def withdraw(acct, amount):
    balance = acct.balance
    time.sleep(0.01)
    acct.balance = 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)
What is the key behavioral difference between ThreadPoolExecutor.map and submit + as_completed?
What happens to a daemon thread when the main thread finishes and the process is ready to exit?
In Python 3.14's free-threaded build (python3.14t), what does sys._is_gil_enabled() report, and what did the measured CPU-bound benchmark show?