A Future is a receipt for a result that isn't ready yet. Master it and orchestrating dozens of concurrent tasks becomes calm and readable.
When you submit() work to a ThreadPoolExecutor, you don't block โ you get a
Future back immediately. It's a placeholder object that will eventually hold the
worker's return value (or the exception it raised). You hold onto it and ask, later, "are you done?"
or "give me the result."
submit() returns instantly with a Future;
future.result() is where you actually block and wait. Separating "start the work"
from "collect the result" is the whole point โ it's what lets many tasks run at once.
from concurrent.futures import ThreadPoolExecutor
import time
def slow_square(n):
time.sleep(0.5)
return n * n
with ThreadPoolExecutor(max_workers=4) as pool:
fut = pool.submit(slow_square, 6) # returns NOW, work runs in background
print(fut.done()) # False โ still pending/running
print(fut.result()) # BLOCKS ~0.5s, then prints 36
print(fut.done()) # True
submit() vs executor.map()Two ways to launch work. They differ in how โ and when โ you get results back.
submit(fn, *args) โ one Future per call. Maximum control: you decide how to collect, in what order, with what timeouts.map(fn, iterable) โ a lazy iterator of results (not futures), in the same order as the input. Convenient, but with a sharp edge.# map: results come back in INPUT order, regardless of finish order
with ThreadPoolExecutor(max_workers=4) as pool:
for result in pool.map(slow_square, [1, 2, 3, 4]):
print(result) # 1, 4, 9, 16 โ always this order
map: because it yields in input order, a slow first item
stalls iteration โ you can't consume item 2 until item 1 is ready, even if 2 finished long ago.
And if any task raised, the exception surfaces only when you reach that item during iteration,
aborting the loop. Great for order-preserving batch work; wrong when you care about latency.
map when you need results aligned to inputs and every
task is roughly equal cost. Use submit + as_completed when task durations vary
and you want to react the moment each one lands.
as_completed(): Process Results as They Land
concurrent.futures.as_completed(futures) yields each future the instant it
finishes โ in completion order, not submission order. This is the key to low latency:
you start handling the fast results without waiting on the slow ones.
from concurrent.futures import ThreadPoolExecutor, as_completed
import time, random
def work(n):
time.sleep(random.uniform(0.1, 1.5)) # wildly varying durations
return n, n * n
with ThreadPoolExecutor(max_workers=5) as pool:
futures = {pool.submit(work, n): n for n in range(8)} # map future -> input
for fut in as_completed(futures):
n = futures[fut] # recover which input this was
_, squared = fut.result() # already done, returns instantly
print(f"n={n} finished first-available โ {squared}")
# Output order tracks who FINISHED, not who was submitted.
as_completed is almost always what you want. The
{future: input} dict is the idiom for remembering which future was which.
wait(): When You Need Only Some Results
Sometimes you don't want to iterate everything โ you want to block until the first task finishes, or
until all do, then move on. concurrent.futures.wait() does exactly that and returns two
sets: done and not_done.
from concurrent.futures import ThreadPoolExecutor, wait, FIRST_COMPLETED, ALL_COMPLETED
with ThreadPoolExecutor(max_workers=4) as pool:
futures = [pool.submit(work, n) for n in range(4)]
# Block only until the FIRST one is done (e.g. "fastest mirror wins")
done, not_done = wait(futures, return_when=FIRST_COMPLETED)
print(f"{len(done)} finished, {len(not_done)} still going")
# Now block for the rest
done, not_done = wait(futures, return_when=ALL_COMPLETED)
print(f"all {len(done)} done")
as_completed vs wait: as_completed streams
futures one-by-one as they finish (you loop). wait blocks once for a condition
(FIRST_COMPLETED or ALL_COMPLETED) and hands back sets. Use wait
for "fire many, act on the first winner" or barrier-style joins; use as_completed to
process every result as it arrives.
A worker's return value and its exceptions are both captured inside the future. Nothing is raised in a background thread and lost โ it's re-raised into your thread when you ask for the result.
def flaky(n):
if n == 3:
raise ValueError(f"n={n} is cursed")
time.sleep(2)
return n
with ThreadPoolExecutor(max_workers=4) as pool:
fut_ok = pool.submit(flaky, 1)
fut_bad = pool.submit(flaky, 3)
# result(timeout=...) raises TimeoutError if not ready in time
try:
print(fut_ok.result(timeout=0.5)) # raises TimeoutError (work takes 2s)
except TimeoutError:
print("not ready yet โ the task keeps running though")
# The worker's exception is RE-RAISED here, in the main thread:
try:
fut_bad.result()
except ValueError as e:
print(f"caught from worker: {e}")
# Or inspect without raising โ returns the exception object, or None:
print(fut_bad.exception()) # ValueError('n=3 is cursed')
submit() a future and never call
.result() (or .exception()) on it, an exception raised inside the worker is
swallowed โ no traceback, no crash, nothing. Always collect your futures.
as_completed + .result() naturally forces every exception to surface.
result(timeout=0.5) raising TimeoutError
does not cancel the task โ the worker keeps running in the background. Timeouts bound your
waiting, not the work itself. There is no safe way to force-kill a running thread; design tasks to
check a stop flag if you need cancellation.
add_done_callback(): React Without Blocking
Instead of polling or blocking on result(), you can attach a callback that fires the moment
the future completes. The callback receives the future itself and runs in whichever thread finished the
work (or immediately, in your thread, if the future is already done).
def on_done(fut):
if fut.exception():
print(f" โ failed: {fut.exception()}")
else:
print(f" โ got {fut.result()}")
with ThreadPoolExecutor(max_workers=4) as pool:
for n in range(4):
pool.submit(flaky, n).add_done_callback(on_done)
# callbacks fire asynchronously as each task lands โ no explicit waiting
as_completed when you want the results back in your own control flow.
| API | What it gives you | Reach for it whenโฆ |
|---|---|---|
submit() | One Future, returns instantly | You want per-task control |
map() | Results in input order | Order matters, tasks are even |
as_completed() | Futures in finish order | Latency matters, durations vary |
wait(...) | done / not_done sets | First-winner or barrier join |
result(timeout=) | Value, or re-raises the worker's error | Collecting a result (bounds YOUR wait) |
exception() | The error object, or None | Inspecting failure without raising |
add_done_callback() | Fire-and-forget on completion | Cheap async reactions |
result, exception, as_completed, or a
callback) โ otherwise worker exceptions vanish silently. Next up: why all this juggling exists in the
first place โ the GIL.