Filter concepts by levelShowing all levels.

Python · Concurrency and Parallelism

Futures

Concepts
2

concurrent.futures' unified interface over both thread-based and process-based concurrency — submitting work, getting a Future back, and the real, narrower rule for what can actually be cancelled.

Python overview

Executors and futures

The same submit()/map() API for both thread and process pools, and everything a Future can tell you about the work behind it.

ThreadPoolExecutor and ProcessPoolExecutor

coreintermediate

concurrent.futures gives threading and multiprocessing the same interface: submit(fn, *args) or map(fn, items). Swap the executor class and the code barely changes — but which one helps depends on I/O-bound vs. CPU-bound.

Think of it as

It's the same job-board pattern either way: you post work (submit) and get back a claim ticket (a Future) to redeem later. ThreadPoolExecutor staffs the board with threads sharing one kitchen (good when workers spend most of their time waiting on something external). ProcessPoolExecutor staffs it with separate kitchens entirely (good when workers are actually busy computing, not waiting).

python
from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=2) as ex:
    future = ex.submit(some_function, arg1, arg2)
    result = future.result()   # blocks until done

What we're doing: Submit one task with ThreadPoolExecutor and observe the Future is not done immediately, then run the same CPU-bound function twice through ProcessPoolExecutor and confirm both results come back correct.

thread_and_process_pools.pypython
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import time

def io_task(name, delay):
    time.sleep(delay)
    return f"{name} done"

def cpu_task(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

if __name__ == "__main__":
    with ThreadPoolExecutor(max_workers=2) as ex:
        future = ex.submit(io_task, "task-A", 0.1)
        print(f"future.done() immediately after submit: {future.done()}")
        result = future.result()
        print(f"result: {result}")

    N = 3_000_000
    t0 = time.perf_counter()
    with ProcessPoolExecutor(max_workers=2) as ex:
        futures = [ex.submit(cpu_task, N) for _ in range(2)]
        results = [f.result() for f in futures]
    t_parallel = time.perf_counter() - t0
    print(f"ProcessPoolExecutor 2 tasks: {t_parallel:.3f}s, results: {results}")
12
submit() hands the work to a background thread and returns right away — the print on the next line runs before io_task has slept its 0.1s, so done() is reliably False here.
22
Both cpu_task(N) calls genuinely run in parallel, in separate processes — this is real CPU parallelism, unlike the same code under ThreadPoolExecutor.
23
Calling .result() on each Future in turn blocks until that specific one finishes — collecting all of them waits for the slowest.
Output
future.done() immediately after submit: False
result: task-A done
ProcessPoolExecutor 2 tasks: 0.378s, results: [8999995500000500000, 8999995500000500000]

Why this works: submit() is asynchronous by design — it queues the call and returns a Future you can check or block on later, which is exactly why done() reads False right after submitting a task that sleeps for 0.1 seconds. The ProcessPoolExecutor run shows both calls landing on the identical, correct total — confirming the parallel computation did not corrupt or duplicate any work, just distributed it across two real processes.

Iterating executor.map() results discards each one's individual error until you reach it

Wrong

python
from concurrent.futures import ThreadPoolExecutor

def might_fail(n):
    if n == 2:
        raise ValueError(f"bad input: {n}")
    return n * 2

with ThreadPoolExecutor(max_workers=3) as ex:
    # all three calls are already running/queued before any exception surfaces
    for result in ex.map(might_fail, [1, 2, 3]):
        print(result)   # raises ValueError only when this loop reaches item 2

Better

python
from concurrent.futures import ThreadPoolExecutor

def might_fail(n):
    if n == 2:
        raise ValueError(f"bad input: {n}")
    return n * 2

with ThreadPoolExecutor(max_workers=3) as ex:
    futures = [ex.submit(might_fail, n) for n in [1, 2, 3]]
    for f in futures:
        try:
            print(f.result())
        except ValueError as e:
            print(f"failed: {e}")   # handle per-task, keep processing the rest

What you see: A single failing item inside executor.map() raises when the loop reaches it, after already silently discarding the chance to see or handle the other results cleanly around it.

Why: executor.map() re-raises an exception from any call the moment its result is consumed, in submission order — it does not give you a way to catch one failure and keep going within the same loop. Submitting with .submit() and handling each Future's .result() individually restores that control, at the cost of writing the loop yourself.

submit() returns a Future immediately, without waiting

ex.submit(fn, args)

returns instantly

Future (pending)

work runs in the background

future.result()

blocks here until it is actually done

  1. ex.submit(fn, args) — returns instantly
  2. Future (pending) — work runs in the background
  3. future.result() — blocks here until it is actually done

Choosing an executor

Choosing an executor
PropertyThreadPoolExecutorProcessPoolExecutor
Best forI/O-bound work (network, disk, waiting)CPU-bound work (real computation)
Underlying unitthreads — share one process, one GILseparate OS processes — no shared GIL
Data passed to workersshared directly, no copyingpickled across the process boundary
Startup cost per workersmalllarger — a new interpreter each

Together

python
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# I/O-bound: fetching several URLs — threads are the right tool
with ThreadPoolExecutor(max_workers=5) as ex:
    results = list(ex.map(fetch_url, urls))

# CPU-bound: hashing several large files — processes actually parallelize this
with ProcessPoolExecutor(max_workers=4) as ex:
    results = list(ex.map(hash_file, filepaths))

Remember: Same API for both — ThreadPoolExecutor for I/O-bound work, ProcessPoolExecutor for CPU-bound. The executor class decides whether you get real parallelism.

See also: cpu bound vs io bound workloads · futures and task lifecycle · multiprocessing basics

Futures, result handling, and cancellation

standardintermediate

A Future represents work that may not be done yet. .result() blocks until it is, re-raising any exception the task raised. as_completed() processes futures in finish order, not submission order. .cancel() only works before the task starts.

Think of it as

A Future is a numbered claim ticket for food you ordered but haven't picked up. .result() is walking up to the counter and waiting until your number is called — if the kitchen burned your order, you find out right then, not when you ordered. Cancelling only works while your ticket is still in the queue; once the kitchen has started cooking, pulling the ticket back does nothing.

python
from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor(max_workers=3) as ex:
    futures = {ex.submit(fn, item): item for item in items}
    for future in as_completed(futures):
        print(future.result())   # arrives in finish order, not submission order

What we're doing: Submit three tasks with different delays and confirm as_completed() yields the fastest one first; then confirm an exception raised inside a task surfaces from .result(); then confirm cancel() succeeds on a queued task but fails on one already running.

futures_lifecycle.pypython
from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def io_task(name, delay):
    time.sleep(delay)
    return f"{name} done"

def raises():
    raise ValueError("boom from worker")

def slow_task():
    time.sleep(2)
    return "finished"

if __name__ == "__main__":
    with ThreadPoolExecutor(max_workers=3) as ex:
        futures = {ex.submit(io_task, f"job-{i}", 0.05 * (3 - i)): i for i in range(3)}
        order = [futures[f] for f in as_completed(futures)]
        print(f"as_completed order (should favor shorter delays first): {order}")

    with ThreadPoolExecutor(max_workers=1) as ex:
        f = ex.submit(raises)
        try:
            f.result()
        except ValueError as e:
            print(f"exception from .result(): {type(e).__name__}: {e}")

    with ThreadPoolExecutor(max_workers=1) as ex:
        f1 = ex.submit(slow_task)   # starts immediately, occupies the only worker
        f2 = ex.submit(slow_task)   # queued -- has not started
        time.sleep(0.05)
        cancelled_running = f1.cancel()
        cancelled_queued = f2.cancel()
        print(f"cancel() on already-running task: {cancelled_running}")
        print(f"cancel() on queued (not-yet-started) task: {cancelled_queued}")
        f1.result()
11
job-2 has the shortest delay (0.05 * (3-2) = 0.05s), job-0 the longest (0.15s) -- as_completed yields whichever genuinely finishes first, independent of submission order.
21
The ValueError raised inside raises() propagates out of .result() unchanged -- a Future does not swallow or wrap task exceptions.
32
f1 is already running (the pool has only 1 worker) by the time cancel() is called -- cancelling a running task is not supported and returns False.
33
f2 never got a worker (f1 is still occupying the only one) so it is still queued -- cancel() removes it from the queue and returns True.
Output
as_completed order (should favor shorter delays first): [2, 1, 0]
exception from .result(): ValueError: boom from worker
cancel() on already-running task: False
cancel() on queued (not-yet-started) task: True

Why this works: as_completed() genuinely reorders by finish time, not submission time — job-2 (0.05s) beats job-1 (0.10s) beats job-0 (0.15s), exactly matching each one's actual delay. The ValueError comes back through .result() as the real exception object, not a generic wrapper — you can catch it by its real type. And the cancellation asymmetry is exactly what the mental model predicts: a task already running cannot be pulled back, but one still waiting in the queue can be removed before it ever starts.

Assuming cancel() stops a task that has already started

Wrong

python
from concurrent.futures import ThreadPoolExecutor
import time

def slow_task():
    time.sleep(5)
    return "finished"

with ThreadPoolExecutor(max_workers=1) as ex:
    future = ex.submit(slow_task)
    time.sleep(0.1)          # slow_task is now running
    future.cancel()           # returns False -- does nothing
    print(future.result())    # still waits out the full 5 seconds

Better

python
import threading
import time

stop_flag = threading.Event()

def cooperative_slow_task():
    for _ in range(50):
        if stop_flag.is_set():
            return "stopped early"
        time.sleep(0.1)
    return "finished"

# elsewhere: stop_flag.set() to request an early exit --
# the task itself must check the flag; nothing can force-stop it

What you see: future.cancel() returns False and the task keeps running to completion — .result() still blocks for however long the task actually takes.

Why: concurrent.futures has no mechanism to forcibly interrupt code that is already executing — cancel() only removes a not-yet-started task from the queue. Stopping running work early requires the task itself to periodically check some shared signal (an Event, a flag) and exit voluntarily; there is no way to cancel it from the outside.

cancel() only succeeds before a task starts running

queued

cancel() → True

running

cancel() → False, already started

done

cancel() → False, nothing to cancel

  1. queued — cancel() → True
  2. running — cancel() → False, already started
  3. done — cancel() → False, nothing to cancel

Future methods

Future methods
MethodBlocks?Effect
.result(timeout=None)yesreturns the value, or re-raises the task's exception
.exception(timeout=None)yesreturns the exception object (or None), without raising it
.done()noTrue if finished (successfully, with an error, or cancelled)
.running()noTrue if currently executing
.cancel()noTrue if successfully cancelled; False if already running or done

Together

python
from concurrent.futures import ThreadPoolExecutor

with ThreadPoolExecutor(max_workers=1) as ex:
    future = ex.submit(some_function)
    if not future.done():
        print("still working...")
    result = future.result()  # blocks here if not already finished

Remember: .result() blocks and re-raises the task's exception. as_completed() yields futures in finish order. cancel() only works before a task starts.

See also: thread and process pool executors · exception chaining

Advertisement