Backend / Async & concurrency / 09_when_to_use_processes.md

When to use processes

Updated 5 interview angles 4 min read source
On this page7
  1. The decision
  2. ProcessPoolExecutor — the usual entry point
  3. Offloading CPU work from an event loop
  4. The cost of processes
  5. When NOT to use processes
  6. Gotchas
  7. Interview angle

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 The GIL, and free-threaded Python; for the full three-way comparison see Python Concurrency Models: Processes, Threads, and Asyncio; for the CPU-vs-I/O distinction see Python Concurrency Models: Processes, Threads, and Asyncio.

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.

python
from concurrent.futures import ProcessPoolExecutor

def heavy(n: int) -> int:
    # pure-Python CPU work
    return sum(i * i for i in range(n))

# REQUIRED on Windows/spawn (see below)
if __name__ == "__main__":
    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 Concurrent.futures in Python: A Comprehensive Guide and ProcessPoolExecutor in Python: A Comprehensive Guide.

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:

python
import asyncio
from concurrent.futures import ProcessPoolExecutor

pool = ProcessPoolExecutor()

async def handler(n):
    loop = asyncio.get_running_loop()
    # CPU work off the loop
    return await loop.run_in_executor(pool, heavy, n)

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 5

  • “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 ProcessPoolExecutor via loop.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.