PYTHON / ADVANCED PYTHON
concurrent.futures for parallel work
Run I/O-bound and CPU-bound Python work in parallel with concurrent.futures, and collect results or exceptions safely from Future objects.
What you will learn
- Pick ThreadPoolExecutor for I/O-bound work, ProcessPoolExecutor for CPU-bound work
- Use map for ordered results, as_completed to handle results as they finish
- Call Future.result() to get a value and to surface the exception a worker raised
- Cancel only works on pending futures; a timeout never stops running work
Understanding concurrent.futures for parallel work
An executor is a pool of workers plus a queue of pending calls. When you hand it a callable with submit, it does not run the call in your thread and does not wait for it: it puts the call on the queue and immediately returns a Future, a small object that will later hold either the return value or the exception. The parallelism happens in the window between submitting and asking for results, so the shape of correct code is always submit everything first, read results second. ThreadPoolExecutor and ProcessPoolExecutor expose exactly the same submit/map/shutdown interface, which is the point of the module: you choose the execution mechanism without rewriting the calling code.
The choice between them follows from the GIL. Only one thread executes Python bytecode at a time, but the interpreter releases the GIL around blocking operations such as socket reads, file reads, and time.sleep, so ten threads waiting on ten sockets really do wait simultaneously. Pure Python computation never releases the GIL voluntarily, so threads just take turns and total wall time does not improve. ProcessPoolExecutor sidesteps this by starting separate interpreter processes with separate GILs, at the cost of pickling arguments and return values and paying process startup, which makes it a loss for tiny tasks.
Collecting results is where the two APIs differ in behaviour, not just style. executor.map submits every item up front but yields results in argument order, and it re-raises the first task's exception at the moment your loop reaches that position, hiding any later results you had not consumed yet. as_completed yields futures in completion order, so a slow first task no longer blocks fast later ones, and each result() call raises only that task's exception. A worker exception is never printed on its own: it sits in the Future until someone calls result() or exception(), which is why discarding futures silently discards failures.
import time
from concurrent.futures import ThreadPoolExecutor
def fetch(page):
time.sleep(0.2) # stands in for a network round trip
return page, len(f"page-{page}") * page
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
results = list(pool.map(fetch, range(1, 5)))
elapsed = time.perf_counter() - start
for page, size in results:
print(f"page {page} -> {size} bytes")
print(f"four 0.2s waits finished in {elapsed:.1f}s")An executor separates submitting work from collecting it, and every submission returns a Future holding either a value or the exception the worker raised.
Worked examples
as_completed with per-task error handling
Handles each result as it arrives and keeps one failing task from losing the other three.
from concurrent.futures import ThreadPoolExecutor, as_completed
def parse(text):
return int(text)
items = ["10", "20", "oops", "40"]
ok, bad = [], []
with ThreadPoolExecutor(max_workers=3) as pool:
futures = {pool.submit(parse, t): t for t in items}
for fut in as_completed(futures):
try:
ok.append(fut.result())
except ValueError as exc:
bad.append((futures[fut], str(exc)))
print(sorted(ok))
print(bad)Example explained
Line 1The dict maps each Future back to its input, because a Future carries no memory of its arguments.
Line 2as_completed yields futures in finish order, which is why ok is sorted before printing instead of trusted.
Line 3fut.result() re-raises ValueError in the main thread, with the traceback from the worker attached.
Line 4The three good tasks are unaffected: one bad input costs you one entry, not the batch.
map stops at the first failure
Shows that map's exception surfaces at iteration position, discarding results computed after it.
from concurrent.futures import ThreadPoolExecutor
def half(n):
return 100 // n
with ThreadPoolExecutor(max_workers=4) as pool:
values = pool.map(half, [4, 5, 0, 2])
try:
for value in values:
print("got", value)
except ZeroDivisionError as exc:
print("map stopped at:", exc)Example explained
Line 1map submits all four calls before yielding anything, so 100 // 2 is actually computed by a worker.
Line 2The failure of the third call is stored in its Future rather than raised where it happened.
Line 3The iterator raises when it reaches position 2, so the successfully computed 50 is never seen.
Line 4Use submit plus as_completed when you need results from the tasks that did succeed.
cancel, timeout, and what they cannot do
Demonstrates that cancel only affects still-queued work and that a timeout does not interrupt a running task.
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError
def slow(x):
time.sleep(0.5)
return x
pool = ThreadPoolExecutor(max_workers=1)
running = pool.submit(slow, 1)
queued = pool.submit(slow, 2)
print("cancel queued:", queued.cancel())
try:
running.result(timeout=0.1)
except TimeoutError:
print("after 0.1s still running:", running.running())
print("final:", running.result())
pool.shutdown()Example explained
Line 1With one worker the second submission stays pending, so cancel() succeeds and returns True.
Line 2result(timeout=0.1) bounds how long the caller waits; the worker keeps sleeping regardless.
Line 3running() confirms the task is mid-execution, which is exactly why cancel() would return False for it.
Line 4The second result(), with no timeout, blocks until the task finishes and then returns 1.
Processes for CPU-bound work
Uses ProcessPoolExecutor so pure Python computation runs on several cores instead of sharing one GIL.
from concurrent.futures import ProcessPoolExecutor
def digit_sum_of_power(n):
return sum(int(c) for c in str(2 ** (n * 50000)))
def main():
with ProcessPoolExecutor(max_workers=2) as pool:
for n, total in zip(range(1, 4), pool.map(digit_sum_of_power, range(1, 4))):
print(n, total)
if __name__ == "__main__":
main()Example explained
Line 1digit_sum_of_power is defined at module top level so each child process can import and unpickle it.
Line 2The __main__ guard is required under the spawn start method, or children re-execute the module and fork endlessly.
Line 3Arguments and return values cross a pipe as pickles, so keep them small: send n, not the huge integer.
Line 4map still yields in argument order even though the two processes finish out of order.
Important notes
Exiting the with block calls shutdown(wait=True), so it blocks until every submitted task finishes; it does not cancel anything for you.
ProcessPoolExecutor needs picklable callables and an if __name__ == "__main__" guard on the spawn start method used by Windows and macOS, and its pickling overhead makes it slower than a plain loop for very short tasks.
Common mistakes
Calling future.result() inside the same loop that submits: each iteration blocks until that task finishes, so the code runs strictly sequentially with added thread overhead.
Using ThreadPoolExecutor for pure Python number crunching and expecting a speedup: the GIL serializes the bytecode, so wall time stays the same or gets slightly worse from context switching.
Ignoring the returned futures or the map iterator: a worker exception stays parked in the Future, nothing is printed, and the task looks like it succeeded.
Try it yourself
Change, predict, then run
Write fake_fetch(n) that sleeps 0.3 seconds and returns n * n, then run it over range(6) twice with ThreadPoolExecutor, once with max_workers=6 and once with max_workers=1, printing the elapsed time for each. Confirm the single-worker run takes about six times longer while both produce identical result lists.
Open the Python workspaceCheck your understanding
You submit five tasks to a ThreadPoolExecutor inside a with block and one of them raises ValueError in its worker. You never call result() or exception() on any of the returned futures. What happens?
- Nothing visible: the exception is stored in that Future, the with block waits for all tasks and exits, and the program reports success
- The exception propagates out of the with block when the executor shuts down, crashing the main thread
- Python prints the worker traceback to stderr the moment the task fails, then continues
- The executor discards that task's Future and reruns the call on another worker
Show answer
A worker catches whatever its callable raises and parks it in the Future, so the exception only reaches you when you call result() or exception(); with nobody asking, the failure disappears. Option two is tempting because the with block does block until every task is done, but shutdown only waits for completion, it never inspects results, so it has no exception to re-raise.