πŸ—ΊοΈ Pattern 5: Pool & map

The original worker-pool API β€” map, imap, starmap, and the one performance knob (chunksize) that turns a slow job fast.

What Pool Is

multiprocessing.Pool keeps a fixed set of worker processes alive and feeds them tasks. It predates concurrent.futures (Pattern 2), and it's still the tool of choice when you want fine control over how work is distributed: lazy streaming, completion-order results, or batching many tiny tasks. For everyday "run N functions, get N results" you can reach for ProcessPoolExecutor β€” but Pool gives you imap and chunksize, which the executor's map exposes only partially.

Key Insight: a pool amortizes the biggest cost in multiprocessing β€” process startup. You pay to spawn the workers once, then reuse them across thousands of tasks. Spawning a fresh Process per task would be dominated by startup overhead.
flowchart LR IT["iterable
[t0, t1, t2, t3, t4, t5, t6, t7]"] --> SP["Pool splits into chunks"] SP --> W1["Worker 1
[t0, t1]"] SP --> W2["Worker 2
[t2, t3]"] SP --> W3["Worker 3
[t4, t5]"] SP --> W4["Worker 4
[t6, t7]"] W1 --> R["results collected
back in the parent"] W2 --> R W3 --> R W4 --> R

The Methods

pool.map(fn, iterable) β€” simplest, ordered, blocking

Splits the iterable into chunks, dispatches them, blocks until every result is ready, and returns a list in the same order as the input. It materializes the whole input and the whole output list in memory.

from multiprocessing import Pool

def square(x):
    return x * x

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        results = pool.map(square, range(10))   # blocks here
    print(results)   # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81] β€” input order

pool.imap(fn, iterable) β€” lazy, ordered, streaming

Returns a lazy iterator. Results are yielded in input order as they become ready, and the input is consumed lazily too β€” so you can feed a huge or even unbounded source without materializing it. Use when the input doesn't fit comfortably in memory, or you want to start processing results before the whole job finishes.

from multiprocessing import Pool

def square(x):
    return x * x

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        # nothing computed until you iterate; results stream in input order
        for result in pool.imap(square, range(1_000_000)):
            if result > 100:
                break   # we never had to build a million-element list

pool.imap_unordered(fn, iterable) β€” completion order

Like imap, but yields each result the moment it's done, ignoring input order. Fastest-finishing tasks come back first. Ideal when order doesn't matter and task durations vary widely β€” you get a steady stream instead of stalling on one slow item at the head of the queue.

import time
from multiprocessing import Pool

def work(x):
    time.sleep(x)   # varying durations
    return x

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        for result in pool.imap_unordered(work, [3, 1, 2, 1]):
            print("done:", result)   # 1, 1, 2, 3 β€” completion order, not input order

pool.starmap(fn, iterable_of_tuples) β€” multi-arg functions

map passes exactly one argument to fn. When your function takes several arguments, starmap unpacks each tuple as positional args β€” fn(a, b) from (a, b).

from multiprocessing import Pool

def add(a, b):
    return a + b

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        results = pool.starmap(add, [(1, 2), (3, 4), (5, 6)])
    print(results)   # [3, 7, 11]

pool.apply_async(fn, args, callback=, error_callback=) β€” one async call

Fires a single call and returns an AsyncResult immediately (non-blocking). Call .get(timeout=) to retrieve the value β€” it re-raises in the parent if the worker threw. Optional callback/error_callback run in the parent when the task succeeds/fails.

from multiprocessing import Pool

def slow_double(x):
    return x * 2

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        handle = pool.apply_async(slow_double, args=(21,))
        # ... do other work in the parent ...
        print(handle.get(timeout=5))   # 42 β€” raises TimeoutError if not ready

chunksize: The Performance Knob

Every dispatch of work to a worker crosses the process boundary β€” that means pickling and an IPC round-trip (Pattern 8). With many tiny tasks, that per-task overhead dwarfs the actual computation. chunksize tells the pool to hand each worker a batch of items per dispatch instead of one at a time β€” collapsing thousands of round-trips into a handful.

from multiprocessing import Pool

def tiny(x):
    return x + 1        # trivial work β€” overhead-dominated

if __name__ == "__main__":
    data = range(1_000_000)
    with Pool(processes=8) as pool:
        # chunksize=1 (default for imap): ~1M IPC hops β†’ slow
        # chunksize=10_000: ~100 hops β†’ often 10-50x faster here
        results = pool.map(tiny, data, chunksize=10_000)
    print(len(results))
The trade-off: too small a chunksize wastes time on IPC overhead. Too large hurts load balancing at the tail β€” if one worker grabs a giant final chunk while the others sit idle, you wait on that straggler. Rule of thumb: aim for chunks big enough that IPC is negligible, small enough that every worker gets several chunks. (Note: pool.map auto-computes a chunksize if you omit it; imap/imap_unordered default to chunksize=1, so set it explicitly for tiny tasks.)
Key Insight: chunksize only matters when tasks are cheap relative to the dispatch cost. If each task already does seconds of real CPU work (Pattern 10), the per-task overhead is noise and the default is fine.

Streaming vs Materializing (Memory)

Key Insight: map materializes all results into one list before returning β€” fine for thousands of small results, ruinous for millions of large ones. imap/imap_unordered stream results one at a time, so peak memory stays flat regardless of job size. On a large job, that's the difference between a steady 50 MB and an OOM kill.
from multiprocessing import Pool

def process_record(record_id):
    return {"id": record_id, "ok": True}   # imagine a big dict

if __name__ == "__main__":
    with Pool(processes=4) as pool:
        written = 0
        # stream: handle each result and let it be garbage-collected
        for out in pool.imap_unordered(process_record, range(10_000_000), chunksize=1_000):
            written += 1   # e.g. append to a file here instead of a list
        print("processed", written)

Pool Lifecycle

A pool holds live child processes and OS resources, so it must be shut down. The clean way is the context manager, which handles teardown for you:

from multiprocessing import Pool

def square(x):
    return x * x

if __name__ == "__main__":
    with Pool(processes=4) as pool:        # workers spawned on entry
        print(pool.map(square, range(5)))
    # on exit: pool.terminate() is called, workers are stopped

Manual control uses three verbs; know the difference between the graceful pair and the abrupt one (Pattern 9 goes deep on shutdown):

CallEffectWhen
pool.close()Stop accepting new tasks; let queued work finishβœ… Normal shutdown, step 1
pool.join()Block until all workers exit (call after close)βœ… Normal shutdown, step 2
pool.terminate()Kill workers immediately, dropping in-flight tasks❌ Only on error / abort
    pool = Pool(processes=4)
    pool.map(square, range(5))
    pool.close()   # no more tasks accepted
    pool.join()    # wait for everything to drain
Gotcha: calling pool.join() before pool.close() (or terminate()) hangs forever β€” join waits for workers that will never be told to stop. Always close (or terminate) first, then join.

Pool vs ProcessPoolExecutor

Both keep a set of workers and fan out tasks. They differ in their result abstraction and how much distribution control they expose.

Aspectmultiprocessing.PoolProcessPoolExecutor
Result objectAsyncResult / plain listFuture (composable, as_completed)
Lazy streamingβœ… imap / imap_unordered❌ map is eager-ish; no true lazy iterator
chunksize controlβœ… everywhereβœ… on map only
Multi-arg helperβœ… starmap❌ (zip args yourself)
Exception handlingRe-raised on .get() / when iteratingRe-raised on future.result()
API feelOlder, more knobsModern, matches ThreadPoolExecutor
Recommendation: default to ProcessPoolExecutor β€” cleaner API, Futures compose with as_completed, and swapping to ThreadPoolExecutor is a one-line change. Reach for Pool specifically when you need imap/imap_unordered streaming, explicit chunksize on iteration, or starmap.

Key Takeaways

Real-world: the canonical Pool job is "process a very large stream of records with cheap-ish per-item work" β€” set a sensible chunksize, use imap_unordered so memory stays flat and slow items don't block, and write each result out as it arrives. For anything else, ProcessPoolExecutor is the friendlier default.