Python Executor.map: Parallel Mapping Explained
python executor map: Learn how to use Executor.map in Python for parallel mapping, including thread vs process pools, error handling, performance tradeoffs, and practi...
python executor map requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
The Executor.map method in Python's concurrent.futures module is a direct way to apply a function to every item in an iterable while distributing the work across a pool of threads or processes. Unlike the built-in map, which executes sequentially, Executor.map returns results in the order the inputs were provided, even though the function calls run concurrently. This makes it a convenient tool for parallelizing CPU-bound or I/O-bound tasks without manually managing futures.
How Executor.map Differs from Built-in map
The built-in map applies a function to each element of an iterable and returns an iterator of results, but it does so synchronously. Executor.map from concurrent.futures does the same thing but schedules the function calls on a pool of workers. The signature is:
Executor.map(func, *iterables, timeout=None, chunksize=1)
For each element in the iterable, func is called, and the results are yielded in the same order as the input. The timeout parameter controls how long to wait for each result. If a call exceeds the timeout, a TimeoutError is raised. The chunksize parameter only applies to ProcessPoolExecutor and controls how many items are sent to each worker at a time, reducing inter-process communication overhead.
Here is a minimal example using ThreadPoolExecutor:
from concurrent.futures import ThreadPoolExecutor def square(x): return x * x with ThreadPoolExecutor(max_workers=4) as executor: results = list(executor.map(square, range(10))) print(results) # [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]
The with block ensures the executor is shut down after the work completes. The map call returns an iterator, so converting it to a list materializes all results.
Choosing Between ThreadPoolExecutor and ProcessPoolExecutor
The two concrete executor classes in concurrent.futures are ThreadPoolExecutor and ProcessPoolExecutor. The choice depends on the nature of the workload.
| Criterion | ThreadPoolExecutor | ProcessPoolExecutor |
|---|---|---|
| Best for | I/O-bound tasks | CPU-bound tasks |
| GIL impact | Limited by GIL for CPU-bound work | Bypasses GIL with separate processes |
| Memory | Shares memory | Separate memory per process |
| Overhead | Lower | Higher (process creation, serialization) |
| chunksize | Not used | Available for batching |
If your function spends most of its time waiting on network calls, file reads, or database queries, ThreadPoolExecutor is usually sufficient and lighter. For pure computation that uses the CPU heavily, ProcessPoolExecutor can achieve true parallelism across multiple cores, but it incurs serialization overhead because inputs and outputs must be pickled between processes.
Controlling Concurrency and Ordering
Executor.map guarantees that results are yielded in the same order as the input iterable. This is convenient when you need to associate results with their original inputs. However, it also means that if one item takes much longer than others, the iterator will block until that result is ready, even if later items have already completed.
To get results as soon as they are available, use executor.submit with as_completed instead. That approach returns futures that can be iterated as they complete, but you lose the input-order guarantee.
The max_workers parameter controls the number of threads or processes in the pool. Setting it too high can cause resource contention, while setting it too low may underutilize available cores. A common heuristic for ProcessPoolExecutor is to set max_workers to the number of CPU cores, but this depends on the workload and memory constraints.
For ProcessPoolExecutor, the chunksize parameter is important for large iterables. Instead of sending each item individually, the executor groups items into chunks and sends them to workers in batches. This reduces pickling overhead and can significantly speed up processing. The default is 1, which sends one item at a time. A larger chunk size, such as 100 or 1000, often improves throughput for CPU-bound tasks, but you must balance it against memory usage.
Handling Exceptions in Worker Functions
When a function passed to Executor.map raises an exception, the exception is not raised immediately. Instead, it is raised when you iterate over the results. This means you can catch exceptions at the point where you consume the iterator.
from concurrent.futures import ThreadPoolExecutor, TimeoutError def risky(x): if x == 3: raise ValueError("bad input") return x * 2 with ThreadPoolExecutor(max_workers=2) as executor: try: results = list(executor.map(risky, range(5))) except ValueError as e: print(f"Caught: {e}")
If you need to handle exceptions per item without stopping the entire iteration, you can wrap the function in a try/except inside the worker, or use executor.submit and inspect each future individually. The map method is not designed for fine-grained error recovery; it stops at the first exception by default.
Performance Considerations and Overhead
The primary performance benefit of Executor.map is that it parallelizes independent function calls. However, the overhead of managing a pool and, for processes, serializing data can outweigh the benefits for very small tasks. For example, calling a trivial function on a list of 10 integers may be slower with a process pool than with a simple loop because the cost of spawning processes and pickling data dominates.
For I/O-bound tasks, threads are cheap, and ThreadPoolExecutor can dramatically reduce wall-clock time when many operations are waiting on external resources. For CPU-bound tasks, ProcessPoolExecutor is the only way to use multiple cores in CPython, but you must be mindful of the chunksize and the amount of data passed between processes.
Memory usage is another concern. Each process in a ProcessPoolExecutor has its own Python interpreter and memory space. If your input iterable is large, passing it to the pool requires pickling, which can be expensive. In contrast, threads share memory, so no serialization is needed, but the GIL prevents true parallelism for CPU-bound code.
Practical Example: Parallel Web Scraping
A common use case for Executor.map is fetching multiple URLs concurrently. Here is an example using ThreadPoolExecutor and the requests library:
import requests from concurrent.futures import ThreadPoolExecutor def fetch_url(url): response = requests.get(url) return response.status_code, len(response.content) urls = [ "https://example.com", "https://httpbin.org/get", "https://jsonplaceholder.typicode.com/posts", ] with ThreadPoolExecutor(max_workers=5) as executor: results = list(executor.map(fetch_url, urls)) for url, (status, size) in zip(urls, results): print(f"{url}: {status}, {size} bytes")
The results are paired with the original URLs because map preserves order. This pattern works well for I/O-bound tasks where the GIL is not a bottleneck.
Avoiding Common Pitfalls with Executor.map
One subtle issue arises when the iterable passed to Executor.map is a generator that is not thread-safe. For example, if you pass a shared mutable iterator that is advanced by multiple workers, you may get duplicate or missing items. To avoid this, convert the generator to a list before calling map, or use an iterable that is safe for concurrent iteration.
Another pitfall is relying on side effects inside the mapped function. Because the function runs in multiple threads or processes, any shared state must be protected with locks or other synchronization mechanisms. For ProcessPoolExecutor, global variables are not shared between processes, so you cannot rely on them to communicate results.
Finally, be careful with the timeout parameter. It applies to each result retrieval, not to the entire map call. If a single function call takes longer than the timeout, a TimeoutError is raised when you try to fetch that result. This can be useful for bounding the execution time of each item, but it does not cancel the underlying work; the function continues running in the background.
When you need to cancel tasks or handle results out of order, Executor.submit with as_completed gives you more control. For many batch-processing scenarios, however, Executor.map provides a clean, readable way to parallelize a function across an iterable with minimal code.