Threading, multiprocessing, or asyncio? The right answer is dictated by your workload โ not by fashion. Match the model to the bottleneck.
Before you type a single import, classify the workload. Everything downstream flows from this
one question, and getting it wrong means the fastest model you can pick will still be the slowest thing
in your stack.
| Dimension | Threading | Multiprocessing | Asyncio |
|---|---|---|---|
| Real parallelism | No (GIL serializes Python bytecode) | Yes โ one interpreter per core | No โ single thread, single core |
| GIL impact | Severe for CPU; irrelevant for I/O (GIL released on blocking calls) | None โ each process has its own GIL | N/A โ cooperative, one task runs at a time |
| Memory model | Shared heap โ cheap sharing, needs locks | Separate address spaces โ copy/pickle to share (IPC) | Shared heap, single thread โ no locks needed |
| Per-unit cost | ~1โ8 MB stack + OS thread (~ยตsโms to spawn) | Whole interpreter, ~10โ50 MB (~10s of ms to spawn) | A coroutine ~few KB (spawn ~nsโยตs) |
| Ideal scale | tens โ low hundreds of concurrent blocking ops | ~= number of CPU cores | thousands โ tens of thousands of I/O tasks |
| Best workload | I/O-bound with blocking/sync libraries | CPU-bound, parallelizable | I/O-bound at very high concurrency, greenfield |
| Library ecosystem | Any sync lib works as-is (requests, psycopg) | Any lib, but args/results must pickle | Needs async libs (httpx, asyncpg, aiokafka) |
CPython's Global Interpreter Lock allows only one thread to execute Python bytecode at a time. For CPU-bound work this means 8 threads on 8 cores run no faster than 1 thread โ they just take turns holding the GIL, and the lock hand-off adds overhead, so it can be slower.
# โ Threads for CPU-bound work โ the GIL serializes them
import time
from concurrent.futures import ThreadPoolExecutor
def crunch(n):
total = 0
for i in range(n):
total += i * i # pure Python bytecode โ holds the GIL
return total
N = 20_000_000
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(crunch, [N] * 8))
print(f"8 threads: {time.perf_counter() - t0:.2f}s") # โ same as running 8x serially
Two important caveats: (1) the GIL is released around blocking I/O syscalls and inside
many C extensions (NumPy, hashlib, compression), so threads do overlap I/O and native compute.
(2) Free-threaded CPython (the experimental 3.13t no-GIL build) changes this
story โ but on standard CPython in production today, assume the GIL is there.
loop.run_in_executor(ProcessPoolExecutor(), ...)), keeping the event loop responsive while
CPU work runs on other cores.
concurrent.futures
The stdlib concurrent.futures module hides threads and processes behind the same
Executor interface โ submit(), map(), as_completed().
Swapping ThreadPoolExecutor โ ProcessPoolExecutor is often a one-line change,
which makes it the perfect harness for A/B benchmarking a workload across models.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def run(executor_cls, fn, items, workers):
with executor_cls(max_workers=workers) as ex:
return list(ex.map(fn, items)) # identical call site for threads OR processes
# threads โ run(ThreadPoolExecutor, task, items, 32)
# procs โ run(ProcessPoolExecutor, task, items, 8)
ProcessPoolExecutor, the function and its arguments must be
picklable and importable at module top level. Lambdas, closures, and locally-defined functions
will raise PicklingError. Threads have no such constraint (shared memory).
Talk is cheap โ time it. Below we run one I/O-bound task and one CPU-bound task under all three models. Fill in a real endpoint / real numbers, but the relative results below are what you should expect on standard CPython.
import time
from concurrent.futures import ThreadPoolExecutor
def io_task(_):
import time
time.sleep(0.1) # stand-in for a network/DB call (GIL released while sleeping)
return 1
def cpu_task(_):
total = 0
for i in range(5_000_000):
total += i * i # pure Python โ holds the GIL
return total
def timed(label, fn, executor, workers, n):
t0 = time.perf_counter()
with executor(max_workers=workers) as ex:
list(ex.map(fn, range(n)))
print(f"{label:>14}: {time.perf_counter() - t0:.2f}s")
timed("threads I/O", io_task, ThreadPoolExecutor, workers=100, n=100) # โ 0.1s โ
overlaps
timed("threads CPU", cpu_task, ThreadPoolExecutor, workers=8, n=8) # โ serial โ GIL
from concurrent.futures import ProcessPoolExecutor
# reuse the SAME io_task / cpu_task / timed defined above (must be module-level to pickle)
timed("procs CPU", cpu_task, ProcessPoolExecutor, workers=8, n=8) # โ 8x faster โ
real cores
timed("procs I/O", io_task, ProcessPoolExecutor, workers=8, n=100) # ok, but wasteful for I/O
# CPU work now spreads across 8 interpreters โ wall time โ single-task time, not 8x it.
import asyncio, time
async def io_task():
await asyncio.sleep(0.1) # non-blocking wait โ the loop runs other tasks meanwhile
return 1
async def main(n):
t0 = time.perf_counter()
await asyncio.gather(*(io_task() for _ in range(n)))
print(f" async I/O: {time.perf_counter() - t0:.2f}s")
asyncio.run(main(10_000)) # โ 0.1s for 10,000 tasks โ one thread, tiny memory footprint
~0.1s (both overlap the wait). But
async does it with one thread and KBs per task, so at 10k+ concurrency it wins on memory
and scheduling overhead. Threads run out of stack RAM and scheduler headroom first.Nร faster (N cores); threads โ serial (GIL);
async โ serial and blocks the whole event loop โ never put CPU work on the loop.Once you've picked a model, the rest of this collection shows how to make it fast and safe under load โ and most techniques apply across all three. Dive deeper into each model:
gather, semaphores, timeouts:
async guides โ