Three tools for doing many things at once. Which one to pick depends on whether your program waits on I/O or burns CPU — because of the GIL.
threading | multiprocessing | asyncio | |
|---|---|---|---|
| Model | OS threads, shared memory | Separate processes, separate memory | One thread, cooperative tasks |
| Best for | I/O-bound (network, disk) | CPU-bound (math, image processing) | Many concurrent I/O waits (thousands of sockets) |
| GIL | Limits CPU parallelism | Bypassed — each process has its own | Not relevant — single thread |
| Cost | Light; needs locks for shared data | Heavy start-up; data must be pickled | Very light; code must be async |
Concurrency vs parallelism
The GIL in detail
What it protects: CPython's internals — above all the reference counts on every object. Without it, two threads doing
refcount += 1could corrupt memory.When it is released: during blocking I/O (
socket.recv,file.read,time.sleep), and inside many C extensions doing heavy work (NumPy,hashlib,zlib). The running thread is also forced to hand it over every few milliseconds (sys.getswitchinterval(), 5 ms by default).What it does NOT do: make your code thread-safe.
counter += 1is several bytecodes, and a switch can happen between them — you still need locks.Free-threaded build: CPython 3.13 added an optional build without the GIL (
python3.13t); in 3.14 it is officially supported (PEP 779) aspython3.14t. Pure-Python threads then run truly in parallel, at a small single-thread cost.
import sys
print(sys.getswitchinterval()) # 0.005
# Only exists on 3.13+; True on the normal build, False on a free-threaded one:
gil = getattr(sys, "_is_gil_enabled", lambda: True)()
print(isinstance(gil, bool)) # TrueWhich tool? A decision flow
Is the work mostly waiting?
Network calls, disk, databases, sleeping → it's I/O-bound.
I/O-bound, a handful to hundreds of tasks
ThreadPoolExecutor— works with ordinary blocking libraries likerequests.I/O-bound, thousands of connections
asynciowith async libraries (aiohttp,httpx,asyncpg) — one thread, tiny per-task cost.Is it computing in Python?
Loops, parsing, math → CPU-bound:
ProcessPoolExecutor/multiprocessing, or vectorise with NumPy, or a free-threaded build.Mixed?
asynciofor the I/O,loop.run_in_executor/asyncio.to_threador a process pool for the heavy bits.
Threads for I/O
from concurrent.futures import ThreadPoolExecutor
import time
def fetch(i):
time.sleep(0.2) # stands in for a network call (releases the GIL)
return i * i
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=5) as pool:
results = list(pool.map(fetch, range(5)))
elapsed = time.perf_counter() - start
print(results, elapsed < 0.5) # [0, 1, 4, 9, 16] TrueProcesses for CPU
from concurrent.futures import ProcessPoolExecutor
def heavy(n):
return sum(i * i for i in range(n))
if __name__ == "__main__": # required: child processes re-import this module
with ProcessPoolExecutor() as pool:
print(list(pool.map(heavy, [10**6] * 4)))asyncio for massive I/O concurrency
import asyncio
async def download(name, delay):
await asyncio.sleep(delay) # yields control while "waiting"
return f"{name} done"
async def main():
results = await asyncio.gather(download("a", 0.1), download("b", 0.1))
print(results) # ['a done', 'b done']
asyncio.run(main())Shared state needs a lock
import threading
counter = 0
lock = threading.Lock()
def work():
global counter
for _ in range(100_000):
with lock: # without the lock, updates can be lost
counter += 1
threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter) # 400000If Python has a GIL, why are threads useful at all?
Does the GIL make my code thread-safe?