Back to Blog
Python

Python ProcessPoolExecutor: Parallel Execution for CPU-Bound Tasks

python processpoolexecutor: Learn how to use Python's ProcessPoolExecutor for CPU-bound parallelism, including map, submit, error handling, and performance tradeoffs.

concurrencyparallelismmultiprocessingconcurrent.futurespython
Illustration of Python ProcessPoolExecutor distributing CPU-bound tasks across multiple processes.

When a Python program is bound by the CPU rather than I/O, the Global Interpreter Lock (GIL) prevents threads from running Python bytecode in parallel. ProcessPoolExecutor from the concurrent.futures module sidesteps that limitation by running tasks in separate processes. This article covers how to use python processpoolexecutor effectively, including its API, error handling, and the tradeoffs involved.

How ProcessPoolExecutor Distributes Work

ProcessPoolExecutor creates a pool of worker processes, each with its own Python interpreter and memory space. When you submit a callable, the executor serializes the function and its arguments using pickle, sends them to an available worker, and collects the returned result after the worker finishes. Because each process runs independently, the GIL does not block parallel execution of CPU-intensive code.

The executor is part of the standard library, so you can import it directly:

from concurrent.futures import ProcessPoolExecutor

The most common way to use it is as a context manager, which ensures that all workers are cleaned up when the block exits:

with ProcessPoolExecutor() as executor: future = executor.submit(pow, 2, 10) result = future.result()

This simple example shows the core pattern: submit() schedules a callable and returns a Future object. Calling result() blocks until the computation finishes and returns the value.

Submitting Tasks with submit() and as_completed()

For workloads where tasks are not known in advance or results should be processed as they complete, use submit() together with as_completed().

from concurrent.futures import ProcessPoolExecutor, as_completed def compute_square(n): return n * n numbers = [1, 2, 3, 4, 5] with ProcessPoolExecutor(max_workers=3) as executor: futures = {executor.submit(compute_square, n): n for n in numbers} for future in as_completed(futures): n = futures[future] try: result = future.result() print(f"Square of {n} is {result}") except Exception as exc: print(f"Task {n} raised {exc}")

as_completed() yields futures in the order they finish, not in submission order. This is useful when tasks have varying durations and you want to start processing results as soon as they are available. The dictionary maps each future back to its original input so you can identify which task produced which result.

Mapping Tasks with map() for Ordered Results

The map() method is a simpler alternative when you have a sequence of inputs and want results in the same order. It behaves like the built-in map(), but distributes the work across the pool.

with ProcessPoolExecutor(max_workers=4) as executor: results = executor.map(compute_square, range(10)) for value in results: print(value)

map() returns an iterator that yields results in the order of the input iterable, regardless of completion order. If any task raises an exception, the exception is raised when you iterate to that position. You can also pass multiple iterables if the callable accepts multiple arguments:

def add(a, b): return a + b with ProcessPoolExecutor() as executor: sums = executor.map(add, [1, 2, 3], [4, 5, 6]) print(list(sums)) # [5, 7, 9]

For large inputs, map() can be memory-efficient because it does not materialize all futures at once. However, it does not give you per-task control over cancellation or early exit.

Handling Exceptions and Timeouts

Exceptions raised inside a worker process are pickled and re-raised in the parent process when you call result(). This means you can catch them just like any normal Python exception, but you should be aware that the original traceback is preserved and the exception object itself must be picklable.

def divide(a, b): return a / b with ProcessPoolExecutor() as executor: future = executor.submit(divide, 1, 0) try: future.result() except ZeroDivisionError as exc: print(f"Caught: {exc}")

If a task runs longer than expected, you can set a timeout on result():

try: result = future.result(timeout=5) except TimeoutError: print("Task did not finish in time") future.cancel()

Note that cancel() only works if the task has not yet started running. If the task is already executing in a worker process, cancellation is not possible; the worker will continue until the task completes or the process is terminated.

ProcessPoolExecutor vs ThreadPoolExecutor

The choice between ProcessPoolExecutor and ThreadPoolExecutor depends on the nature of the workload. The table below summarizes the key differences:

CriterionProcessPoolExecutorThreadPoolExecutor
GIL impactBypasses the GILLimited by the GIL for Python code
Memory modelSeparate memory space per processShared memory within one process
OverheadHigher (process creation, pickling)Lower (thread creation)
Best suited forCPU-bound tasksI/O-bound tasks
Data sharingRequires pickling and IPCDirect access to shared objects

For CPU-bound tasks that involve pure Python computation, ProcessPoolExecutor is usually the right choice because it allows true parallel execution across multiple CPU cores. For I/O-bound tasks such as network requests or file reads, threads are more efficient because they avoid the overhead of process creation and serialization.

Performance Overhead and When It Pays Off

Using ProcessPoolExecutor introduces two main sources of overhead: process startup and data serialization. Each worker process must be spawned, which is more expensive than creating a thread. Additionally, every callable and its arguments must be pickled before being sent to the worker, and the result must be pickled and sent back.

For small tasks that finish in microseconds, the serialization and IPC cost can dominate, making the pool slower than sequential execution. The overhead becomes worthwhile when the task itself is computationally heavy enough to amortize these costs. As a rule of thumb, if a single task takes more than a few milliseconds and involves significant CPU work, the pool can provide a meaningful speedup.

You can control the number of workers with the max_workers parameter. Setting it to the number of CPU cores is a common starting point, but the optimal value depends on the workload and whether the tasks release the GIL (e.g., when using NumPy or other C extensions).

import os from concurrent.futures import ProcessPoolExecutor with ProcessPoolExecutor(max_workers=os.cpu_count()) as executor: # distribute work

Common Pitfalls: Pickling, Global State, and Startup Cost

Because each worker runs in a separate process, all data passed to tasks must be picklable. This excludes lambdas, nested functions, and objects that cannot be serialized. If you need to pass a complex object, consider defining it at the module level or using a fork start method on Linux, which inherits memory without pickling.

The start method affects how workers are created. On Linux and macOS, the default is fork, which is fast but can be unsafe with threads. On Windows, the default is spawn, which re-imports the main module in each worker. This means you must guard the entry point with if __name__ == "__main__": to avoid recursive process creation.

Global state is not shared between processes. If you modify a global variable inside a task, the change is not visible to the parent or to other workers. Each worker has its own copy of the module-level state at the time of process creation. This is a common source of confusion when porting threaded code to a process pool.

Finally, be mindful of the startup cost. Creating a pool with many workers takes time, so it is best to create the executor once and reuse it for multiple batches of tasks rather than creating a new pool for each small set of calls. The context manager pattern ensures proper cleanup, but you can also call shutdown() explicitly when you need more control over the pool's lifetime.

When used correctly, ProcessPoolExecutor is a reliable way to parallelize CPU-bound work in Python. Understanding its serialization requirements, overhead, and differences from threads allows you to decide when it is the right tool and how to structure your code to avoid common failure modes.

python processpoolexecutor: Practical Usage and Code Example | RYUSLOG DEV