โš™๏ธ Pattern 2: ProcessPoolExecutor

The high-level default for CPU parallelism: it spawns the workers, collects results as Futures, propagates exceptions, and cleans up โ€” and it's one word away from being threads.

The Pitch

concurrent.futures.ProcessPoolExecutor is the modern, high-level way to run work across cores. Raw Process makes you plumb queues, join by hand, and invent your own error handling. The executor does all of it: it keeps a fixed pool of worker processes alive, feeds them tasks, hands back a Future for each, and re-raises any worker exception when you read the result.

Key Insight: reach for ProcessPoolExecutor first. Drop down to raw Process only for a few long-lived, hand-managed workers, and down to multiprocessing.Pool only when you specifically need imap/chunksize control.

The Unifier: One Word Swaps Threads โ†” Processes

The whole point of concurrent.futures is a single API over two very different execution models. Threads and processes share the exact same submit / map / Future surface โ€” you switch bottleneck strategies by changing one class name.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

def work(x):
    return x * x

if __name__ == "__main__":
    # I/O-bound? threads. CPU-bound? processes. Same code below the swap:
    Executor = ProcessPoolExecutor        # <-- change this ONE word to ThreadPoolExecutor
    with Executor() as pool:
        print(list(pool.map(work, range(6))))   # [0, 1, 4, 9, 16, 25]
Key Insight: identical API, opposite trade-offs. Threads share memory (cheap data, blocked by the GIL on CPU work); processes isolate memory (real parallelism, but every argument and result is pickled and copied). Choosing between them is the whole decision guide โ€” but once chosen, the calling code barely changes.

submit() and Futures

submit(fn, *args) schedules one call and immediately returns a Future โ€” a handle to a result that doesn't exist yet. Call .result() to block until it's ready (and to receive the value, copied back from the worker).

flowchart LR T["tasks"] --> SU["pool.submit(fn, x)"] SU --> F["Future
(pending)"] F --> W["worker process
runs fn"] W --> R["Future.result()
value (or re-raised error)"] F -.->|"as_completed()"| AC["yields Futures
as they finish"]
from concurrent.futures import ProcessPoolExecutor, as_completed

def slow_square(x):
    total = 0
    for i in range(x * 5_000_000):   # variable CPU work per task
        total += i
    return x, x * x

if __name__ == "__main__":
    with ProcessPoolExecutor(max_workers=4) as pool:
        futures = [pool.submit(slow_square, x) for x in range(6)]
        # as_completed yields each Future the moment ITS worker finishes,
        # not in submission order โ€” good for streaming results.
        for fut in as_completed(futures):
            arg, result = fut.result()   # blocks only if this one isn't done yet
            print(f"{arg} -> {result}")

Useful Future methods: .result(timeout=), .exception() (returns the raised exception instead of raising it), .done(), and .cancel() (only works while still queued, never once running).

map() vs submit + as_completed

Two ways to fan out; pick by whether you care about order or latency.

pool.map(fn, iterable)submit + as_completed
Result orderโœ… input order (a slow first task holds up the rest)completion order (fastest first)
Ergonomicsโœ… one line, feels like map()more code, a Future per task
Per-task argsone arg (zip for more)โœ… arbitrary *args, **kwargs
Streaming / first-done-firstโŒ noโœ… yes
Exceptionsraised while iterating resultsraised at each .result()
from concurrent.futures import ProcessPoolExecutor

def work(x):
    return x * x

if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:
        # map: simplest, results come back IN ORDER
        ordered = list(pool.map(work, range(6)))     # [0, 1, 4, 9, 16, 25]

        # map with several arg columns via zip / positional lists:
        def add(a, b): return a + b
        # NOTE: map passes one item per iterable, column-wise:
        sums = list(pool.map(add, [1, 2, 3], [10, 20, 30]))   # [11, 22, 33]
        print(ordered, sums)
Gotcha: map returns a lazy iterator โ€” nothing is actually collected until you consume it (e.g. list(...)). It also preserves input order, so one slow task at the front delays everything behind it. Use as_completed when you want to react to results as they land.

max_workers and Why More โ‰  Faster

max_workers defaults to os.cpu_count(). For CPU-bound work, that's the right ceiling: each worker saturates one core, so with N cores you get ~Nร— speedup and no more. Adding workers beyond core count doesn't add cores โ€” it just adds processes contending for the same cores, plus extra interpreter memory and context-switching overhead.

import os
from concurrent.futures import ProcessPoolExecutor

if __name__ == "__main__":
    print("cores:", os.cpu_count())
    # Default is fine for CPU work; set it explicitly to leave a core for the OS/UI:
    with ProcessPoolExecutor(max_workers=os.cpu_count() - 1) as pool:
        ...
Key Insight: "more workers than cores helps" is a rule for I/O-bound pools (workers spend most of their time waiting). For pure CPU work it backfires. The theoretical ceiling and where it flattens out is Pattern 10 (Amdahl's law).

Exceptions: Silent Until You Read

This is the Python gotcha that bites everyone. A worker exception does not crash the pool and does not print. It is captured, pickled, and stashed in that task's Future. You never see it until you read the result โ€” at which point it re-raises in your process, with the original traceback attached as its cause.

from concurrent.futures import ProcessPoolExecutor, as_completed

def risky(x):
    if x == 3:
        raise ValueError(f"cannot process {x}")   # blows up inside the worker
    return x * 10

if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:
        futures = {pool.submit(risky, x): x for x in range(6)}
        for fut in as_completed(futures):
            x = futures[fut]
            try:
                print(x, "->", fut.result())   # re-raises here for x == 3
            except ValueError as e:
                print(f"task {x} failed: {e}")   # handle per-task, keep going
Gotcha: if you never call .result() (or iterate a map result), the exception is silently swallowed and your program looks like it "worked". Always consume results. A related trap: if the exception object itself isn't picklable, propagating it back can fail โ€” see Pattern 8.

Always Use It as a Context Manager

A pool holds real OS processes. Leak it and you leave orphaned interpreters running. The with block calls shutdown(wait=True) on exit, which stops accepting new tasks and blocks until every in-flight task finishes and every worker is torn down.

from concurrent.futures import ProcessPoolExecutor

def work(x):
    return x * x

if __name__ == "__main__":
    with ProcessPoolExecutor() as pool:        # spins up workers
        results = list(pool.map(work, range(1000)))
    # <-- shutdown(wait=True) ran here: all tasks done, all workers gone
    print(sum(results))

    # Equivalent without the sugar:
    pool = ProcessPoolExecutor()
    try:
        results = list(pool.map(work, range(1000)))
    finally:
        pool.shutdown(wait=True)   # never skip this
Real-world: in a long-running service, create the pool once at startup and reuse it โ€” don't spin one up per request. Spawning processes costs tens of milliseconds each, so a per-request pool pays that tax constantly. Graceful shutdown, cancel_futures= (3.9+), and draining in-flight work are covered in Pattern 9.

Key Takeaways

Aspectsubmit() + as_completedmap()
Returnsa Future per calla lazy result iterator
Result ordercompletion orderinput order
Best forstreaming, mixed durations, per-task error handlinguniform tasks, simplest code
Exception surfacesat fut.result()while iterating results
Multiple argumentsโœ… nativevia multiple iterables (column-wise)
Real-world: ProcessPoolExecutor covers ~90% of "make this CPU work parallel" needs. Use map for a clean fan-out, submit+as_completed when latency or per-task errors matter, always inside with, always guarded by if __name__ == "__main__". Reach lower only for imap/chunksize (Pattern 5).