When to use processes
Reach for multiprocessing when the work is CPU-bound and written in pure Python. Processes are the only one of Python’s three concurrency models that achieves true parallelism on multiple cores, because each process has its own interpreter and its own GIL.
For the GIL itself see 01_gil.md; for the full three-way comparison see 33_threading_vs_asyncio_vs_mp.md; for the CPU-vs-I/O distinction see 16_cpu_io_tasks.md.
The decision
The GIL lets only one thread execute Python bytecode at a time, so threads don’t parallelize CPU work. The way around it is multiple processes — multiple interpreters, multiple GILs, real parallelism.
| Workload | Use | Why |
|---|---|---|
| CPU-bound (parsing, math, compression, image processing) | processes | sidesteps the GIL → real parallelism across cores |
| I/O-bound, many connections (HTTP, DB, files) | asyncio | one thread, cheap context switches |
| I/O-bound, blocking libraries | threads | GIL is released during blocking I/O |
| Mixed | async + a process pool for the CPU parts | offload CPU work via run_in_executor |
The litmus test: is the bottleneck the CPU or waiting? Waiting → threads/async. Burning CPU in Python → processes.
ProcessPoolExecutor — the usual entry point
The high-level API; prefer it over raw multiprocessing.Process for parallel computation.
from concurrent.futures import ProcessPoolExecutor
def heavy(n: int) -> int:
return sum(i * i for i in range(n)) # pure-Python CPU work
if __name__ == "__main__": # REQUIRED on Windows/spawn (see below)
with ProcessPoolExecutor() as pool:
results = list(pool.map(heavy, [10_000_000] * 8))
On 8 cores this runs ~8x faster than a thread pool would for this work. The same code with ThreadPoolExecutor shows almost no speedup — the GIL serializes it. See 19_concurrent_futures.md and 20_process_pool_executor.md.
Offloading CPU work from an event loop
In an async service, never run a heavy CPU function inline — it blocks the loop. Push it to a process pool:
import asyncio
from concurrent.futures import ProcessPoolExecutor
pool = ProcessPoolExecutor()
async def handler(n):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(pool, heavy, n) # CPU work off the loop
The cost of processes
Processes are not free — this is why they’re a last resort, not a default:
- Startup overhead — spawning an interpreter is far heavier than a thread.
- No shared memory — each process has its own address space. Data crossing the boundary is pickled and copied (IPC), which can dominate runtime for large inputs/outputs.
- Everything must be picklable — arguments and return values. Lambdas, local functions, open sockets/file handles can’t cross.
- Higher memory — N processes ≈ N copies of the interpreter and imported modules.
If the per-task data is large and the computation small, IPC cost can erase the parallelism gain. Batch the work so each task does enough to amortize the transfer.
When NOT to use processes
- I/O-bound work — you’d pay process overhead for no parallelism benefit; the GIL is already released during I/O. Use threads or async.
- Tiny tasks — startup + pickling cost exceeds the work. Batch them or stay single-process.
- Heavy shared mutable state — coordinating it across processes (locks,
Manager, shared memory) is complex; threads share memory for free. - The CPU work is already in C — NumPy, Pandas, Polars, and many native libs release the GIL internally, so threads can parallelize them without separate processes.
Gotchas
if __name__ == "__main__":guard — required on Windows and macOS (default spawn start method) or you get infinite process spawning /RuntimeError. Linux defaults to fork, which is more forgiving but has its own pitfalls (forking a process with threads/locks can deadlock).- fork vs spawn — fork copies the parent (fast, but inherits locks/fds and is unsafe with threads); spawn starts fresh (safe, but re-imports your module). Know which your platform uses.
- Pickling errors — “can’t pickle local object” usually means you passed a lambda or nested function; use a module-level function.
- Exceptions cross the boundary — an exception in a worker is re-raised when you read the future’s
.result().
Interview angle
- “When would you use multiprocessing over threads in Python?” — for CPU-bound pure-Python work. Threads can’t parallelize CPU because of the GIL; separate processes each have their own GIL, giving true multi-core parallelism.
- “Why not just always use processes then?” — they’re expensive: heavy startup, no shared memory (args/results are pickled and copied), higher RAM. For I/O-bound or tiny tasks the overhead outweighs any gain.
- “How do you run CPU work inside an async service?” — offload it to a
ProcessPoolExecutorvialoop.run_in_executor, so the event loop isn’t blocked. - “What must be true of data passed to a process?” — it must be picklable; it’s serialized and copied across the process boundary, which is also why large payloads can negate the speedup.
- “What’s the
if __name__ == '__main__'guard about?” — with the spawn start method (Windows/macOS) the child re-imports the module; without the guard it would recursively spawn processes. It’s mandatory there.