Back to Blog
Python

Python Concurrent Futures: Thread and Process Pools

python concurrent futures: Learn to use Python's concurrent.futures module to run tasks in thread and process pools, collect results, handle failures, and pick the rig...

concurrencyThreadPoolExecutorProcessPoolExecutoras_completedGILparallel execution
A clean editorial illustration showing two parallel execution pools routing task cards to worker threads and processes, with results emerging on the right side.

python concurrent futures requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.

What concurrent.futures Provides

Python's concurrent.futures module provides a high-level interface for running callables asynchronously. Instead of managing threads or processes directly, you work with an Executor object that accepts tasks and returns Future objects representing pending results.

The module ships with two concrete executors:

  • ThreadPoolExecutor runs tasks in a pool of worker threads.
  • ProcessPoolExecutor runs tasks in a pool of worker processes.

Both share the same API, so switching between them is often a matter of changing one import and one constructor call. That shared interface is the main reason developers reach for concurrent.futures over raw threading or multiprocessing code: the orchestration logic stays the same regardless of which executor you choose.

Submitting Tasks and Collecting Results

The simplest way to use an executor is the submit method, which schedules a callable and returns a Future immediately:

from concurrent.futures import ThreadPoolExecutor def square(n): return n * n with ThreadPoolExecutor(max_workers=4) as executor: future = executor.submit(square, 10) result = future.result() print(result) # 100

The result() call blocks until the task completes. If the callable raised an exception, result() re-raises it at the call site, which makes error handling feel similar to synchronous code.

For bulk operations, map is more convenient. It applies a function to every item in an iterable and returns results in input order:

from concurrent.futures import ThreadPoolExecutor def square(n): return n * n 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]

map preserves ordering, which is useful when the result position matters. The downside is that you cannot start processing results until the slowest earlier task finishes, because results are yielded in order.

Processing Results as They Complete

When tasks have uneven durations, waiting for results in submission order wastes time. The as_completed function yields futures in the order they finish:

from concurrent.futures import ThreadPoolExecutor, as_completed import random import time def fetch(url): # Simulate variable network latency delay = random.uniform(0.1, 1.0) time.sleep(delay) return url urls = ["https://example.com/a", "https://example.com/b", "https://example.com/c"] with ThreadPoolExecutor(max_workers=3) as executor: futures = {executor.submit(fetch, url): url for url in urls} for future in as_completed(futures): url = futures[future] print(f"Finished {url}: {future.result()}")

The dictionary mapping futures back to their inputs is a common pattern here, because as_completed only gives you the Future object, not the original argument.

Choosing Between Threads and Processes

The most important decision when using python concurrent futures is which executor to instantiate. The choice depends heavily on what your tasks actually do.

CPU-bound tasks

Python threads cannot execute Python bytecode in parallel because of the Global Interpreter Lock (GIL) in CPython. For CPU-bound work such as image processing, hashing, or numerical computation, ThreadPoolExecutor will not give you parallelism — it will only add thread scheduling overhead.

Use ProcessPoolExecutor for CPU-bound tasks. Each worker runs in a separate process with its own interpreter, so the GIL does not constrain them.

from concurrent.futures import ProcessPoolExecutor import hashlib def compute_checksum(data): # CPU-heavy work return hashlib.sha256(data).hexdigest() with ProcessPoolExecutor(max_workers=4) as executor: results = list(executor.map(compute_checksum, chunks))

I/O-bound tasks

For network requests, file reads, or database queries, the thread spends most of its time waiting on I/O. During that wait, the GIL is released, so other threads can run. ThreadPoolExecutor is the right choice here because threads are cheaper to create than processes and share memory, so passing arguments back and forth does not require serialization.

Task typeRecommended executorReason
CPU-boundProcessPoolExecutorAvoids GIL, gives true parallelism
I/O-boundThreadPoolExecutorGIL released during I/O waits, threads are lightweight
MixedEvaluate per taskSeparate the workload or use both executors

Process overhead

Processes do not share memory. Arguments and return values must be pickled and sent over a pipe. If your tasks pass large objects, the serialization cost can dominate the actual computation. In that case, a thread-based approach may be faster even for CPU-heavy work, because no pickling is needed.

Handling Exceptions and Timeouts

A Future captures the outcome of a task, including failures. When a task raises an exception, the exception is stored in the future and re-raised when you call result():

from concurrent.futures import ThreadPoolExecutor def risky_task(value): if value < 0: raise ValueError("negative values not allowed") return value * 2 with ThreadPoolExecutor(max_workers=2) as executor: future = executor.submit(risky_task, -5) try: result = future.result() except ValueError as exc: print(f"Task failed: {exc}")

The exception is raised in the caller's thread, not in the worker, so you can handle it with normal try/except blocks. This is a significant advantage over raw threading, where an unhandled exception in a worker thread would print a traceback and terminate that thread without any clean way to propagate the error.

result() also accepts a timeout argument. If the task does not finish within the given seconds, it raises TimeoutError:

future = executor.submit(slow_task) try: result = future.result(timeout=2.0) except TimeoutError: print("Task did not finish in time")

Note that a timeout does not cancel the task. The worker keeps running in the background; only the wait is abandoned. If you need to cancel, use future.cancel(), but cancellation only works while the task is still queued and has not started running.

Cancellation and Cleanup

The Future.cancel() method attempts to cancel a task. It returns True if the task was cancelled before any worker picked it up, and False if the task is already running or finished. Once a task is running, there is no safe way to stop it from the outside — the executor will wait for it to complete.

Using the executor as a context manager ensures that shutdown() is called automatically:

with ThreadPoolExecutor(max_workers=4) as executor: # tasks submitted here pass # executor.shutdown(wait=True) runs on exit

By default, shutdown(wait=True) blocks until all pending futures finish. If you pass wait=False, the method returns immediately, but the interpreter will not exit until the worker threads are done. In practice, the context manager form with default settings is the safest choice for most scripts.

A Practical Example: Parallel HTTP Fetching

Putting the pieces together, here is a realistic pattern for fetching multiple URLs concurrently with ThreadPoolExecutor:

from concurrent.futures import ThreadPoolExecutor, as_completed import urllib.request def fetch_url(url): with urllib.request.urlopen(url, timeout=5) as response: return url, response.status, len(response.read()) urls = [ "https://example.com", "https://httpbin.org/status/200", "https://httpbin.org/status/404", ] with ThreadPoolExecutor(max_workers=5) as executor: futures = {executor.submit(fetch_url, url): url for url in urls} for future in as_completed(futures): try: url, status, size = future.result() print(f"{url}: {status} ({size} bytes)") except Exception as exc: print(f"Request failed: {exc}")

The max_workers value controls how many requests run concurrently. Setting it too high can overwhelm the remote server or exhaust local file descriptors; setting it too low means the pool underutilizes available bandwidth. For I/O-bound workloads, a good starting point is often several times the number of CPU cores, but the right value depends on the external service's limits.

Where This Approach Breaks Down

concurrent.futures is not a general-purpose parallel programming toolkit. It works well for independent tasks, but it has limitations you should know about before building a large system on it.

Tasks must be independent

The executor model assumes tasks do not need to coordinate with each other. If one task depends on the result of another, you either chain them inside a single callable or fall back to lower-level synchronization primitives. The module does not provide a way to express dependencies between futures.

Shared state is problematic

With ThreadPoolExecutor, worker threads share memory, so you need locks or other synchronization if multiple tasks mutate the same object. With ProcessPoolExecutor, there is no shared memory at all — each task gets a copy of its arguments. Mutating a shared object inside a process pool task has no effect on the parent process.

Pickling constraints

Process pool arguments and return values must be picklable. Lambdas, local functions, and some objects cannot be pickled, which limits what you can pass to a process pool task. A common workaround is to define the task function at module level.

Task granularity matters

The overhead of submitting a task and collecting a result is small but not zero. If each task takes microseconds, the orchestration overhead can exceed the task duration. In that case, batching work into larger chunks or using a different approach such as multiprocessing.Pool with chunksize may be more effective.

python concurrent futures: Practical Usage and Code Examples | RYUSLOG DEV