Three tools for doing more than one thing at once. This is the decision guide, told from the multiprocessing side β pick the right one and the code writes itself.
multiprocessing β many processes, each with its own
interpreter and its own GIL. The only standard-library way to get
true CPU parallelism in pure Python. Memory is isolated; data crosses via
pickling and IPC. (This is what the whole site is about.)threading β many threads in one process, sharing memory.
The single GIL means they interleave rather than parallelize on CPU, but the GIL is released on
blocking I/O β so threads are great for overlapping waiting.asyncio β a single thread running an event loop that
juggles thousands of tasks, switching only at await. Cheapest per task, but every
library in the path must be async-aware.The process column is the one this site cares about β read it first, then contrast.
| multiprocessing | threading | asyncio | |
|---|---|---|---|
| True CPU parallelism | β Yes β a GIL each | β No (one GIL) | β No (one thread) |
| Memory | Isolated; pickle/IPC to share | Shared by default | Shared (same thread) |
| Data-passing cost | Serialization per call (the tax) | Free (shared objects) | Free (shared objects) |
| Per-unit cost | Heaviest β full process + startup | Heavy β OS thread, ~MBs of stack | Tiny β a coroutine object |
| Realistic scale | ~ number of CPU cores | tensβlow hundreds | tens of thousands |
| Switching | Preemptive (OS, separate) | Preemptive (OS, anywhere) | Cooperative (only at await) |
| Race conditions | Rare β no shared memory | Everywhere β need locks | Rare β switches are predictable |
| Best workload | CPU-bound number crunching | Blocking I/O w/ sync libraries | Massive concurrent I/O |
| Library requirement | Args/results must be picklable | Any (works w/ requests) | Needs async libs (httpx, asyncpg) |
| Complexity | Medium (serialization, start method, guard) | Medium (locks, races) | High (async all the way down) |
requests, a sync DB driver) β threads. Shared memory, no
pickling, and the GIL frees on the wait.
concurrent.futures is the great unifier: the same code becomes threads
or processes by swapping one class name. That makes it trivial to A/B a workload and let the numbers
tell you which model your problem actually wants. See Pattern
2 for the full ProcessPoolExecutor treatment.
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
def work(item):
... # do something with item
return item
if __name__ == "__main__": # REQUIRED for the process pool (spawn re-imports)
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))
if __name__ == "__main__":
asyncio.run(main(["https://example.com"] * 1000)) # 1000 tasks, one thread
ThreadPoolExecutor and
ProcessPoolExecutor share an API, moving CPU work from "accidentally serialized on the
GIL" to "truly parallel across cores" is a one-line change β no rewrite. The async version, by
contrast, is a different program: every call in the path must be awaitable.
__main__ guard. On macOS and Windows the
default is spawn: a fresh interpreter per worker that re-imports your
module. Without if __name__ == "__main__": around the launch, each child
re-runs the launch on import β endless process explosion. This has no analogue in threading or
async. See Pattern 7.ProcessPoolExecutor
with 8 workers, each running NumPy on top of a BLAS library that itself spawns 8 native threads,
gives you 64 threads fighting over 8 cores β slower than either alone. Pin it with
OMP_NUM_THREADS=1 (or the BLAS-specific var) so the two layers of parallelism don't
multiply.chunksize) or don't use processes at all.
asyncio web server
that handles thousands of connections on one thread, but offloads each CPU-bound job to a
ProcessPoolExecutor so the heavy work runs on other cores without freezing the
event loop. Another: a thread pool that shells expensive work out to worker processes. You are not
choosing one model for the whole program β you are choosing one per kind of work.
The bridge from an async server to real cores is loop.run_in_executor. Await a
CPU-bound function as if it were async; under the hood it runs in a separate process and the event
loop keeps serving other requests:
import asyncio
from concurrent.futures import ProcessPoolExecutor
def crunch(n): # top-level + picklable β safe for a process pool
total = 0
for i in range(n):
total += i * i
return total
async def handle_request(loop, pool, n):
# offload CPU work to a worker process; the event loop stays free meanwhile
result = await loop.run_in_executor(pool, crunch, n)
return result
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool: # a GIL per worker β real parallelism
results = await asyncio.gather(
*(handle_request(loop, pool, 30_000_000) for _ in range(4))
)
print(results)
if __name__ == "__main__": # REQUIRED β the pool spawns worker processes
asyncio.run(main())
| 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 |
| An async server with CPU jobs | hybrid | run_in_executor + process pool: loop stays free, work goes parallel |