Backend / Python core / Stdlib / 07_concurrent_futures.md

concurrent.futures

Updated 5 interview angles 7 min read source
On this page9
  1. submit and Future
  2. map versus as_completed
  3. Threads, processes or interpreters
  4. What a process pool demands
  5. Cancellation, timeouts and shutdown
  6. From asyncio
  7. Where it breaks in production
  8. Related
  9. Interview angle

concurrent.futures

One API, three executors. ThreadPoolExecutor, ProcessPoolExecutor and, since 3.14, InterpreterPoolExecutor take the same calls — submit, map, shutdown — and hand back the same Future, so the choice between them is one line. The interview questions are about what that line hides: the GIL, pickling, and what a Future can and cannot do once it is running.

submit and Future

python
from concurrent.futures import ThreadPoolExecutor

def fetch(url):
    return http.get(url).json()

with ThreadPoolExecutor(max_workers=10) as pool:
    fut = pool.submit(fetch, "https://example.com")   # returns at once
    fut.running(), fut.done()                          # state, non-blocking
    data = fut.result(timeout=5)                       # blocks; re-raises

A Future is a handle to work that may not have started. result() blocks until it has finished and re-raises whatever the callable raised, so the exception surfaces where you read the result, not where it happened. exception() returns it instead of raising. The with block calls shutdown(wait=True), so nothing escapes the block still running.

add_done_callback(fn) runs fn(future) when the work finishes — in whichever thread completes the future (a pool worker, or the process pool’s manager thread), or immediately in the caller if it is already done. Anything the callback touches needs to be thread-safe, which is why callbacks are usually a queue put and nothing more.

map versus as_completed

python
from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor(max_workers=10) as pool:
    for page in pool.map(fetch, urls):          # results in input order
        store(page)

    futs = {pool.submit(fetch, u): u for u in urls}
    for fut in as_completed(futs):              # results in finish order
        url = futs[fut]
        try:
            store(fut.result())
        except Exception as e:                  # one failure, not the batch
            log.warning("%s failed: %s", url, e)

map is the cleaner call and has three edges. It submits every item up front — a million URLs means a million queued futures, with no backpressure. It yields results in order, so one slow item stalls everything behind it. And an exception is raised from the iterator when its turn comes, ending the loop: the futures still queued are cancelled and the ones already running finish into the void.

as_completed fixes all three at the cost of bookkeeping; feeding map in slices fixes the memory one, and Python 3.14 adds buffersize= to bound the queue directly.

Threads, processes or interpreters

Threads Processes
Parallelism GIL serialises bytecode real, one interpreter each
Fits I/O: HTTP, disk, DB CPU: parsing, numerics
Arguments shared memory pickled both ways
Start cost microseconds fork/spawn, milliseconds
Default size min(32, CPUs + 4) os.process_cpu_count()

InterpreterPoolExecutor sits between them: one process, one interpreter per worker thread, each with its own GIL, with the callable, arguments and results pickled as for processes. On Windows a process pool is capped at 61 workers.

Measured: the same CPU work in each

Four tasks of pure-Python arithmetic, one taking 0.12 s, four workers, on CPython 3.14.5:

Executor 4 tasks
ThreadPoolExecutor 0.49 s
ProcessPoolExecutor 0.15 s
InterpreterPoolExecutor 0.16 s
threads, 3.14t 0.17 s

The choice is the workload. Threads for anything that waits on I/O, because the GIL is released while waiting; processes for CPU work that does not release it. Native extensions — NumPy, hashlib, compression — release the GIL themselves, so threads often win there too.

Python 3.14 adds a third executor, InterpreterPoolExecutor — one sub-interpreter per worker, each with its own GIL, in one process. The GIL, the free-threaded build and sub-interpreters are in The GIL, and free-threaded Python.

What a process pool demands

python
from concurrent.futures import ProcessPoolExecutor
from itertools import batched

def parse(chunk):                       # module-level: it has to be importable
    return [heavy(line) for line in chunk]

if __name__ == "__main__":              # spawn re-imports this module
    chunks = batched(lines, 10_000)     # amortise the per-task pickling
    with ProcessPoolExecutor(initializer=load_model) as pool:
        for rows in pool.map(parse, chunks):
            ...

Everything crosses a pipe pickled — the function by qualified name, the arguments and results by value. So lambdas, nested functions and open connections cannot be submitted; large arrays cost more to ship than to compute on; and per-task overhead means small tasks must be batched.

How much batching matters: pool.map(tiny, range(100_000)) on four processes took 48.4 s with the default chunksize=1, and 0.06 s with chunksize=1000. Every item was a round trip through a pipe; batched, a few hundred round trips carry all of them. chunksize is ignored by thread pools, where there is no pipe.

initializer runs once per worker for the expensive setup — a model, a client — that must not be re-created per task. max_tasks_per_child (3.11) replaces each worker after N tasks, which contains memory leaks in native code. When a worker dies (OOM, segfault) the pool raises BrokenProcessPool and is unusable: catch it and build a new one.

Since 3.14, terminate_workers() and kill_workers() stop a pool’s processes from outside, the one way to end work that is already running. Detail in ProcessPoolExecutor: what processes add.

Cancellation, timeouts and shutdown

python
with ThreadPoolExecutor() as pool:
    fut = pool.submit(slow)
    try:
        fut.result(timeout=5)
    except TimeoutError:
        fut.cancel()                 # True only if it never started
    pool.shutdown(wait=False, cancel_futures=True)   # drop the queue (3.9+)

cancel() only works on a future still in the queue; a running thread or process cannot be stopped from outside, and a timeout on result() stops your waiting, not the work. That is the honest answer to “how do you time out a task in a thread pool”: you do not — the function has to check a flag or use a socket timeout, or you run it in a process you can kill.

For fail-fast over a batch, wait returns as soon as anything fails:

python
futs = [pool.submit(work, i) for i in items]
done, pending = wait(futs, return_when=FIRST_EXCEPTION)   # returns at the first failure
for f in pending:
    f.cancel()              # only the ones still queued; running ones finish

From asyncio

python
result = await asyncio.to_thread(sync_fn, arg)             # default pool
result = await loop.run_in_executor(process_pool, cpu_fn)  # your pool

An asyncio Future and a concurrent.futures.Future are different classes; run_in_executor wraps the second in the first, and asyncio.wrap_future(f) does the same for a future you got elsewhere, so a coroutine can await it. Which pool, how to size it, and contextvars propagation are in run_in_executor and asyncio.to_thread.

Where it breaks in production

  • The exception nobody reads. A future whose result() is never called swallows its exception silently. Always consume results, or attach a callback that logs.
  • map over a huge input. Everything is submitted at once; memory grows with the input, not the pool.
  • Blocking callbacks. add_done_callback runs in the worker; a callback that does I/O or takes a lock stalls the pool.
  • A pool per request. Creating ThreadPoolExecutor() inside a handler spawns threads on every call. Make one at startup and share it.
  • Process pool at import time. With spawn, the child re-imports the module and creates another pool. Guard with if __name__ == "__main__".
  • Shutdown that never returns. shutdown(wait=True) waits for running work; a task stuck on a socket without a timeout holds the process open.

Interview angle 5

  • “100 HTTP requests in parallel?” - a ThreadPoolExecutor with as_completed, bounded to ~10-30 workers by the server you are hitting, not the CPU. Or asyncio.gather with an async client if the code is already async. Follow-up: why not map? It submits everything up front and returns in order, so one slow request delays every result behind it.
  • “Threads or processes?” - by whether the work releases the GIL. I/O waits release it, so threads; pure-Python CPU work does not, so processes, paying pickling for every argument and result. Native code often releases it too, which is why NumPy in threads can be the right answer.
  • “Your process pool is slower than a loop.” - per-item round trips: each item is pickled and sent through a pipe. Batch with chunksize: 100,000 tiny items took 48 s at chunksize=1 and 0.06 s at chunksize=1000.
  • “How do you cancel a running task?” - you cannot. cancel() only removes a queued future; a timeout on result() stops the wait, not the work. Cooperative flag, socket timeouts, or a process you can kill.
  • “Where does a worker’s exception go?” - into the future. It is re-raised by result(), and if nobody calls result() it is lost. That is the bug: fire-and-forget submit with no one reading the outcome.