Back to Blog
Python

Python Multiprocessing Pool: Usage and Pitfalls

Learn how to use Python's multiprocessing.Pool for parallel task execution: map, apply, async variants, error handling, and performance considerations.

multiprocessingconcurrencypythonparallelismperformance
Illustration of a Python multiprocessing pool distributing tasks to multiple worker processes.

multiprocessing.Pool runs a function across multiple inputs using a pool of worker processes. It is a practical way to parallelize CPU-bound Python code, but you need to understand its API, error handling, and performance tradeoffs.

What multiprocessing.Pool Does

Python's multiprocessing.Pool provides a simple way to run a function across multiple input values using a pool of worker processes. Each worker runs in its own Python interpreter, so CPU-bound code can execute in parallel on multiple cores, bypassing the Global Interpreter Lock (GIL) that limits threads. The pool manages the distribution of tasks, collects results, and handles the underlying inter-process communication.

The basic pattern is to create a Pool, call one of its methods to submit work, and then close or terminate the pool when done. The most common methods are map, apply, and their asynchronous variants.

Creating a Pool and Understanding Its Arguments

A Pool is created with multiprocessing.Pool(processes=None). If you omit processes, Python uses the number of CPU cores returned by os.cpu_count(). That is often a reasonable default, but it is not always optimal. For I/O-bound tasks, more processes than cores may help; for CPU-bound tasks, using more than the number of cores can cause context-switching overhead without a throughput gain.

from multiprocessing import Pool def square(x): return x * x if __name__ == "__main__": with Pool(4) as pool: results = pool.map(square, range(10)) print(results)

The with statement releases the pool's resources when the block exits. For synchronous calls like this, no tasks are pending at that point. For asynchronous work, use close() and join() as shown below so callbacks have time to run. Note that on Windows and macOS with the default spawn start method, the code that creates the pool must be guarded by if __name__ == "__main__" to avoid recursive process creation. The remaining examples assume this guard is present when needed.

Using Pool.map for Parallel Mapping

Pool.map(func, iterable) applies func to each element of iterable and returns a list of results in the same order. It blocks until all tasks complete. This is the simplest way to parallelize a loop that has no dependencies between iterations.

def process_line(line): # simulate CPU-bound work return sum(ord(c) for c in line) lines = ["alpha", "beta", "gamma"] with Pool() as pool: counts = pool.map(process_line, lines)

map chunks the iterable into batches and sends them to workers, reducing communication overhead. You can control the chunk size with the chunksize parameter. A larger chunksize can improve performance when each task is very small, because it reduces the number of IPC round trips. However, it also delays the start of processing for later items and can cause an uneven distribution if the iterable length is not a multiple of the chunk size.

apply and apply_async for One-Off Tasks

Pool.apply(func, args) executes func with the given arguments in one worker and blocks until the result is ready. It is useful when you need to run a function exactly once, but it does not provide parallelism by itself. To submit multiple independent calls without blocking, use apply_async.

def add(a, b): return a + b with Pool() as pool: result = pool.apply(add, (2, 3)) print(result) # 5 async_result = pool.apply_async(add, (4, 5)) print(async_result.get()) # 9

apply_async returns an AsyncResult object immediately. Calling get() blocks until the result is available. You can also pass a callback function that is called when the result is ready, which avoids blocking entirely.

def callback(result): print(f"Got: {result}") with Pool() as pool: pool.apply_async(add, (10, 20), callback=callback) pool.close() pool.join()

close() prevents new tasks from being submitted, and join() waits for all workers to finish. This is required when using callbacks because the pool may exit before the callback runs if you do not wait.

Handling Exceptions and Error Propagation

When a task raises an exception, the behavior depends on the method you use. With map, the exception is re-raised in the parent by the map call itself; map does not return partial results. With apply_async, the exception is captured and re-raised when you call get().

def risky(x): if x == 3: raise ValueError("bad value") return x * 2 with Pool() as pool: try: results = pool.map(risky, range(5)) except ValueError as e: print(f"Caught: {e}")

The worker traceback is included in the parent exception. If a result or exception cannot be pickled, the parent may see a multiprocessing.pool.MaybeEncodingError instead of the original error. To handle errors per task without failing the whole map call, use apply_async with an error_callback, or iterate over imap and catch exceptions where they occur.

Choosing Pool Size and Chunking

The optimal number of worker processes depends on the workload. For CPU-bound tasks, a good starting point is os.cpu_count(), but you should leave one core free for the main process if it does other work. For I/O-bound tasks, you may need more processes because they spend most of their time waiting.

Chunking is relevant for map and imap. The chunksize parameter controls how many items are sent to a worker at once. A small chunk size (like 1) gives the best load balancing but increases IPC overhead. A large chunk size reduces overhead but can lead to a tail effect where one worker finishes long after others. A common heuristic is to use len(iterable) // (processes * 4) as a starting point, but you should measure with your actual workload.

Performance and Overhead Considerations

Using a pool introduces several overheads that are often overlooked. First, each task's arguments and return values must be pickled and unpickled, which is expensive for large objects. Second, there is the cost of inter-process communication through pipes or queues. Third, starting worker processes takes time, so a pool is only beneficial when the total work is substantial compared to these overheads.

For very short tasks, the overhead can dominate, making the pool slower than a simple loop. A common pattern is to batch work into larger units. For example, if you are processing a list of 10,000 small items, you might split it into 100 chunks of 100 items and pass each chunk as a single argument to a function that processes the whole chunk.

def process_chunk(chunk): return [transform(item) for item in chunk] chunks = [data[i:i+100] for i in range(0, len(data), 100)] with Pool() as pool: results = pool.map(process_chunk, chunks)

This reduces the number of pickling operations and IPC round trips dramatically.

Another overhead is memory. Each worker process has its own Python interpreter and memory space. If your function uses a large amount of memory, the total memory usage can be several times the baseline. This is especially important when using fork on Linux, where workers inherit the parent's memory copy-on-write. With spawn, workers start fresh, so they do not inherit large objects unless you pass them explicitly.

Common Pitfalls and How to Avoid Them

Pickling Limitations

Anything passed to a pool method or returned from a function must be picklable. This includes the function itself when using map or apply. Top-level functions defined in a module are picklable, but lambdas and nested functions generally are not. If you need to pass a function that is defined inside another function, you will get a PicklingError. The solution is to define the function at module level or use functools.partial with a top-level function.

Shared State and Global Variables

Each worker process has its own copy of the module's globals. If you modify a global variable in a worker, the change is not visible to other workers or the parent. This is a common source of bugs when developers expect shared state across processes. If you need shared state, use multiprocessing.Value, Array, or a Manager, but be aware of the synchronization overhead.

Deadlocks with close, join, and get

Callbacks run in a result-handler thread in the parent process, not in a worker process. Calling get() inside a callback for a result that has not arrived can block that handler thread and prevent other results from being processed. Calling pool.join() while tasks are still being submitted can also hang. Always call close() before join(), and avoid worker functions that wait on tasks managed by the same pool.

The if __name__ == "__main__" Guard

On Windows and macOS with the default spawn start method, the pool creation code is re-executed in each worker. Without the guard, the workers will try to create their own pools recursively, causing an infinite loop or a crash. Always guard the entry point of your script.

When Not to Use a Pool

A pool is not the right tool for every parallel problem. If your tasks are I/O-bound, threads or asyncio may be a better fit because they avoid the overhead of process creation and IPC. If you need to share large amounts of data frequently, the pickling cost can negate the benefits of parallelism. If your tasks have dependencies or require fine-grained coordination, a pool's simple map/apply model may be too restrictive.

For embarrassingly parallel problems with independent CPU-bound tasks, multiprocessing.Pool is often the simplest and most effective solution. For more complex workflows, consider using Process and Queue directly, or use concurrent.futures.ProcessPoolExecutor, which offers a similar API with a few extra features like future objects.

Python Multiprocessing Pool: Practical Usage and Code Examples | RYUSLOG DEV