Three tools for doing more than one thing at once. This is the decision guide β pick the right one and the code writes itself.
threading β many threads in one process, sharing memory.
The GIL means they interleave, not parallelize, on CPU. Great for overlapping blocking I/O.multiprocessing β many processes, each with its own
interpreter and its own GIL. The only stdlib way to get true CPU parallelism in pure Python.
Memory is isolated; data crosses via pickling/IPC.asyncio β a single thread running an event loop that
juggles thousands of tasks, switching only at await. Cheapest per-task, but needs
async-aware libraries.| threading | multiprocessing | asyncio | |
|---|---|---|---|
| True CPU parallelism | β No (one GIL) | β Yes (a GIL each) | β No (one thread) |
| Memory | Shared by default | Isolated; pickle/IPC to share | Shared (same thread) |
| Data-passing cost | Free (shared objects) | Serialization overhead per call | Free (shared objects) |
| Per-unit cost | Heavy β OS thread, ~MBs of stack | Heaviest β full process + startup | Tiny β a coroutine object |
| Realistic scale | tensβlow hundreds | ~ number of CPU cores | tens of thousands |
| Switching | Preemptive (OS, anywhere) | Preemptive (OS, separate) | Cooperative (only at await) |
| Race conditions | Everywhere β need locks | Rare β no shared memory | Rare β switches are predictable |
| Best workload | Blocking I/O w/ sync libraries | CPU-bound number crunching | Massive concurrent I/O |
| Library requirement | Any (works w/ requests) | Args/results must be picklable | Needs async libs (httpx, asyncpg) |
| Complexity | Medium (locks, races) | Medium (serialization, spawn) | High (async all the way down) |
requests, a sync DB driver) β threads.
concurrent.futures is the great unifier: the same code becomes threads
or processes by swapping one class. That makes it trivial to A/B a workload and see which model your
problem actually wants.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def work(item):
... # do something with item
return item
items = range(100)
# I/O-bound β threads (shared memory, cheap, works with sync libs)
with ThreadPoolExecutor(max_workers=16) as pool:
results = list(pool.map(work, items))
# CPU-bound β processes (true parallelism; `work` & args must pickle)
with ProcessPoolExecutor() as pool: # same API, one word changed
results = list(pool.map(work, items))
And the async equivalent, which lives in its own world with await:
import asyncio, httpx # note: an ASYNC http client, not `requests`
async def fetch(client, url):
r = await client.get(url) # yields control here β loop runs others
return r.status_code
async def main(urls):
async with httpx.AsyncClient() as client:
return await asyncio.gather(*(fetch(client, u) for u in urls))
asyncio.run(main(["https://example.com"] * 1000)) # 1000 tasks, one thread
requests.get, a heavy CPU loop, time.sleep) freezes the entire
event loop and every task on it. Threads tolerate blocking calls; async does not.
spawn β a fresh interpreter per process, so module-level code re-runs and you need
the if __name__ == "__main__": guard.threads lets you keep
every synchronous library you already use; async makes you replace them.ProcessPoolExecutor via
loop.run_in_executor, or a thread pool that shells work out to worker processes. Pick
the model per workload, not per program.
| If your work is⦠| Use | Because |
|---|---|---|
| CPU-bound (pure Python) | processes | Only way past the GIL for real parallelism |
| A handful of blocking I/O tasks, sync libs | threads | Simple, shares memory, GIL frees on I/O |
| Thousands of I/O tasks you can rewrite | async | Scales huge for almost no memory |
| Threads vs processes, same code | concurrent.futures | Swap ThreadβProcess pool executor, one line |
await in detail β see the async guides.