Concurrent Futures
The concurrent.futures module gives threads and processes a single, uniform Executor/Future API — submit work, get a Future back, and let the module manage the pool, callbacks, cancellation, and exceptions for you.
concurrent.futures is a standard-library module that provides a high-level, uniform interface for running work asynchronously with either a pool of threads or a pool of processes. Instead of managing threading.Thread or multiprocessing.Process objects by hand, you submit callables to an Executor and get back Future objects representing work in progress — with a consistent API for waiting on results, attaching callbacks, cancelling, and handling exceptions, regardless of which kind of pool is underneath.
What Is concurrent.futures
The module centers on two abstractions: an Executor, which manages a pool of workers and accepts callables to run, and a Future, which represents the eventual result of one submitted call. Two concrete executors ship with the standard library: ThreadPoolExecutor (a pool of threads) and ProcessPoolExecutor (a pool of separate processes) — both implement the exact same Executor interface.
Why It Matters
Before concurrent.futures, using threads meant managing threading.Thread objects, queues for results, and manual exception handling by hand; using processes meant the same, plus manual serialization concerns. concurrent.futures unifies both behind one small API, so switching between thread-based and process-based parallelism for a piece of code is often a one-line change (which Executor subclass you instantiate).
Executor and submit
Executor.submit(fn, *args, **kwargs) schedules fn to run and immediately returns a Future — it does not block waiting for the result. Executors are context managers; using 'with' ensures the pool is properly shut down (waiting for pending work) when the block exits.
from concurrent.futures import ThreadPoolExecutor
def square(n: int) -> int:
return n * n
with ThreadPoolExecutor(max_workers=4) as pool:
future = pool.submit(square, 5)
print(future.result()) # 25, blocks until the call completesFuture Objects
A Future represents a computation that may not have finished yet. Its key methods: result(timeout=None) blocks until the call completes (or raises TimeoutError after timeout seconds) and returns its value or re-raises its exception; exception(timeout=None) similarly blocks and returns the raised exception object (or None on success); done() reports completion without blocking; cancel() attempts to cancel a call that hasn't started running yet.
from concurrent.futures import ThreadPoolExecutor
def might_fail(x: int) -> int:
if x < 0:
raise ValueError("x must be non-negative")
return x * 2
with ThreadPoolExecutor() as pool:
fut = pool.submit(might_fail, -1)
print(fut.done()) # likely False immediately, True once complete
err = fut.exception() # blocks until finished; returns the ValueError
print(type(err), err)Exception Propagation
If the submitted callable raises, the Future stores the exception rather than raising it immediately (since nothing may be waiting yet). Calling result() later re-raises that same exception in the calling thread, which is why wrapping result() in try/except is the normal way to observe failures — an exception in background work is never silently lost, but it also won't surface until something asks for the result.
from concurrent.futures import ThreadPoolExecutor
def divide(a: int, b: int) -> float:
return a / b
with ThreadPoolExecutor() as pool:
fut = pool.submit(divide, 10, 0)
try:
fut.result()
except ZeroDivisionError as exc:
print("caught:", exc)Callbacks with add_done_callback
future.add_done_callback(fn) registers fn to be called with the Future itself as its only argument once the Future completes (successfully, with an exception, or cancelled) — useful for reacting to completion without blocking on result(). If the Future is already done when the callback is added, it runs immediately (in the calling thread).
The callback itself runs in whatever thread completed the future (a pool worker thread, or the calling thread if already done) — keep callbacks short and non-blocking.
from concurrent.futures import ThreadPoolExecutor
def report(fut):
if fut.exception() is not None:
print("failed:", fut.exception())
else:
print("succeeded:", fut.result())
with ThreadPoolExecutor() as pool:
future = pool.submit(pow, 2, 10)
future.add_done_callback(report)as_completed and map
concurrent.futures.as_completed(futures) yields each Future as it finishes, in completion order rather than submission order — useful for processing results as soon as they're ready. Executor.map(fn, iterable) is a convenience that submits fn for every item and returns an iterator of results in the original submission order (blocking on each in turn as you iterate).
from concurrent.futures import ThreadPoolExecutor, as_completed
def work(n: int) -> int:
return n * n
with ThreadPoolExecutor(max_workers=3) as pool:
futures = [pool.submit(work, n) for n in range(5)]
for fut in as_completed(futures):
print(fut.result()) # order depends on completion time, not submission
# map: simpler, but blocks in submission order
for result in pool.map(work, range(5)):
print(result) # 0 1 4 9 16, in orderCancellation
future.cancel() only succeeds if the call has not yet started running; it returns True if cancellation succeeded and False otherwise (including when the call is already running or finished). There is no way to forcibly interrupt a callable that has already started executing — concurrent.futures cannot stop code mid-execution, it can only prevent not-yet-started work from starting.
This is a common point of confusion: cancel() is not a kill switch for running work. Cooperative cancellation (the callable checking a flag periodically) is the only way to stop already-running work early.
Thread vs Process Executors
ThreadPoolExecutor runs callables in worker threads within the same process — cheap to start, shares memory directly, but in CPython (implementation detail) is subject to the GIL, so it helps with I/O-bound work (network calls, file I/O, waiting) far more than CPU-bound work. ProcessPoolExecutor runs callables in separate worker processes, each with its own Python interpreter and memory space — genuinely parallel for CPU-bound work, but arguments, results, and exceptions must be pickled to cross process boundaries, adding serialization overhead and requiring picklable callables (no lambdas or local closures as the submitted function).
Executor Good for CPython GIL impact IPC cost
ThreadPoolExecutor I/O-bound work limits CPU parallelism none (shared memory)
ProcessPoolExecutor CPU-bound work true parallelism pickling every argument/resultPractical Usage: Choosing max_workers
For I/O-bound work with ThreadPoolExecutor, max_workers can reasonably exceed the CPU core count since threads spend most of their time waiting. For CPU-bound work with ProcessPoolExecutor, max_workers close to the number of CPU cores is typical — more processes than cores mostly adds contention and overhead rather than throughput.
Edge Cases
A callable submitted to ProcessPoolExecutor must be importable/picklable — a lambda or a function defined inside another function will fail to pickle. Exceptions raised inside a process-pool worker are re-raised in the parent when you call result(), but the traceback's original frames are from a different process and may print somewhat differently. Forgetting to use the executor as a context manager (or call shutdown()) can leave worker threads/processes running longer than intended.
Best Practices
Always use an executor as a context manager so it shuts down cleanly. Prefer as_completed when you want to react to results as they arrive, and map when you just need results in order with simple code. Always call result() (or exception()) on every submitted Future somewhere, so failures aren't silently dropped. Choose ThreadPoolExecutor for I/O-bound work and ProcessPoolExecutor for CPU-bound work.
Complete Example
A CPU-bound check (primality testing) is a natural fit for ProcessPoolExecutor; wrapping submit() in a dict maps each Future back to its input for reporting.
from concurrent.futures import ProcessPoolExecutor, as_completed
def is_prime(n: int) -> bool:
if n < 2:
return False
return all(n % d for d in range(2, int(n ** 0.5) + 1))
def main() -> None:
numbers = [15485863, 15485867, 15485917, 15485921]
with ProcessPoolExecutor() as pool:
futures = {pool.submit(is_prime, n): n for n in numbers}
for fut in as_completed(futures):
n = futures[fut]
try:
result = fut.result()
except Exception as exc:
print(f"{n}: error {exc}")
else:
print(f"{n}: {'prime' if result else 'not prime'}")
if __name__ == "__main__":
main()Mental Model
Think of an Executor as a work-order desk: submit() drops off a job and hands you a claim ticket (the Future) immediately — you don't wait in line. You can check the ticket later (done()), come back and collect the result when ready (result()), ask to be notified automatically (add_done_callback), or, if the job hasn't started yet, try to cancel the order (cancel()).
Quick Reference
ThreadPoolExecutor(max_workers=None) thread pool
ProcessPoolExecutor(max_workers=None) process pool
executor.submit(fn, *a, **kw) -> Future schedule one call
executor.map(fn, iterable) -> iterator schedule many, ordered results
future.result(timeout=None) block for value / re-raise exception
future.exception(timeout=None) block for exception object (or None)
future.done() / future.cancel() check / try to cancel
future.add_done_callback(fn) run fn(future) on completion
as_completed(futures) yield futures as they finishKey Takeaways
concurrent.futures gives threads and processes a single, uniform Executor/Future API — submit() returns a Future immediately, without blocking.
Exceptions raised by submitted work are stored on the Future and re-raised when result() is called, never silently lost.
cancel() only works on work that hasn't started yet; there is no way to forcibly stop already-running work.
ThreadPoolExecutor suits I/O-bound work (limited by CPython's GIL for CPU-bound work); ProcessPoolExecutor gives true parallelism for CPU-bound work at the cost of pickling overhead across process boundaries.
with ThreadPoolExecutor() as pool: pool.submit(risky_task) # result()/exception() never called — a failure here is silently swallowed with ThreadPoolExecutor() as pool: future = pool.submit(risky_task) future.result() # re-raises any exception here, where it can be handled/logged
A Future's exception only surfaces when something calls result() or exception() on it; never doing so means failures in background work go unnoticed.
with ProcessPoolExecutor() as pool: pool.submit(lambda: compute()) # lambda is not picklable def compute_task(): return compute() with ProcessPoolExecutor() as pool: pool.submit(compute_task) # a top-level, picklable function
ProcessPoolExecutor must pickle the submitted callable to send it to a worker process; lambdas and locally defined closures cannot be pickled and raise an error.
What does Executor.submit() return, and does it block until the work finishes?
It returns a Future representing the eventual result immediately, without blocking; the caller decides when (and whether) to block by calling result() later.
How does a Future communicate that its callable raised an exception?
The exception is stored on the Future rather than raised immediately; calling future.result() re-raises it in the calling code, or future.exception() returns the exception object directly.
Can future.cancel() stop a callable that has already started executing?
No — cancel() only succeeds for work that has not yet started running; there is no mechanism to forcibly interrupt already-running work.
Why does ProcessPoolExecutor require its submitted callables to be picklable, while ThreadPoolExecutor does not?
ProcessPoolExecutor sends work to separate processes, which requires serializing (pickling) the callable and its arguments to cross the process boundary; ThreadPoolExecutor's worker threads share the same process memory, so no serialization is needed.
When would ThreadPoolExecutor fail to speed up a CPU-bound task in CPython, even with several worker threads?
Because CPython (implementation detail) has a GIL that lets only one thread run Python bytecode at a time; CPU-bound work doesn't release the GIL the way I/O-bound waiting does, so threads mostly serialize rather than run in true parallel.
What's the difference between as_completed(futures) and executor.map(fn, iterable) in terms of result ordering?
as_completed yields Futures in the order they actually finish, which may differ from submission order; executor.map returns results in the original submission order, blocking on each in turn as needed.
Write code that submits five tasks to a ThreadPoolExecutor and prints each result as soon as it's ready, using as_completed.
from concurrent.futures import ThreadPoolExecutor, as_completed import time, random def task(n: int) -> int: time.sleep(random.uniform(0.01, 0.05)) return n * n with ThreadPoolExecutor(max_workers=5) as pool: futures = [pool.submit(task, n) for n in range(5)] for fut in as_completed(futures): print(fut.result()) as_completed(futures) yields each Future the moment it finishes, in completion order rather than submission order, letting the loop print results as soon as they're available instead of waiting for earlier-submitted, slower tasks first.
This code never calls result() or exception() on fut. What happens to the ZeroDivisionError raised inside divide?
It is stored on the Future but never observed — nothing calls result() or exception(), so the exception is silently discarded; the program prints 'submitted' and 'after with block' with no visible sign that divide failed.
Explain why choosing ProcessPoolExecutor over ThreadPoolExecutor for a CPU-bound number-crunching function typically improves throughput on a multi-core machine.
ProcessPoolExecutor runs each worker in a separate OS process with its own Python interpreter, so CPU-bound work in different workers can execute truly in parallel across cores; ThreadPoolExecutor's worker threads, in CPython, are limited by the GIL to running one thread's Python bytecode at a time, so CPU-bound work doesn't get real parallelism there.
from concurrent.futures import ThreadPoolExecutor
def divide(a, b):
return a / b
with ThreadPoolExecutor() as pool:
fut = pool.submit(divide, 5, 0)
print("submitted")
print("after with block")