backend / python core / stdlib / 07_concurrent_futures.md

concurrent.futures — thread/process pools with a unified API

3 min read source

concurrent.futures — thread/process pools with a unified API

concurrent.futures provides ThreadPoolExecutor and ProcessPoolExecutor — high-level pools for running callables and getting Future results back. The interface is identical for both, making it easy to swap.

Basic usage

from concurrent.futures import ThreadPoolExecutor

def fetch(url):
    ...
    return result

with ThreadPoolExecutor(max_workers=10) as pool:
    future = pool.submit(fetch, "https://example.com")
    result = future.result()        # blocks until done

map — parallel map over an iterable

urls = ["https://...", "https://...", ...]

with ThreadPoolExecutor(max_workers=10) as pool:
    for result in pool.map(fetch, urls):
        process(result)

map preserves order. If you don’t care about order and want to process as soon as each finishes, use as_completed:

from concurrent.futures import ThreadPoolExecutor, as_completed

with ThreadPoolExecutor() as pool:
    futures = {pool.submit(fetch, url): url for url in urls}
    for future in as_completed(futures):
        url = futures[future]
        try:
            result = future.result()
        except Exception as e:
            print(f"{url} failed: {e}")

ThreadPool vs ProcessPool — when to use each

ThreadPool ProcessPool
Concurrency model shared memory, GIL serializes Python bytecode separate processes, true parallelism
Best for I/O-bound (HTTP, file, DB) CPU-bound (number crunching, parsing, encoding)
Communication overhead none — same memory pickling args + results between processes
Pickling required no yes — args & return values must be picklable
Setup cost low high (fork/spawn)
Default workers min(32, cpu_count + 4) cpu_count

Rule of thumb:

  • Network calls, disk I/O → threads
  • CPU work, big numerical arrays → processes (or NumPy/native libs that release the GIL)

Pickling constraints (ProcessPoolExecutor)

Functions and arguments passed to ProcessPoolExecutor must be picklable:

  • OK: top-level functions, modules, dataclasses
  • NOT OK: lambdas, local/nested functions, methods of dynamically-created classes
def square(x):     # top-level
    return x * x

with ProcessPoolExecutor() as pool:
    list(pool.map(square, range(10)))

# This fails:
with ProcessPoolExecutor() as pool:
    list(pool.map(lambda x: x * x, range(10)))   # PicklingError

Use multiprocessing.dummy (alias for ThreadPool) when you have a CPU-bound function but need lambda/closure compatibility.

Cancellation and timeouts

with ThreadPoolExecutor() as pool:
    future = pool.submit(slow_thing)

    try:
        result = future.result(timeout=5)
    except TimeoutError:
        future.cancel()   # only effective if not yet started

Future.cancel() only works for tasks not yet started. Once running, you can’t kill a thread/process safely from outside.

wait — finer control

from concurrent.futures import wait, FIRST_COMPLETED, FIRST_EXCEPTION, ALL_COMPLETED

with ThreadPoolExecutor() as pool:
    futures = [pool.submit(work, x) for x in items]

    done, not_done = wait(futures, timeout=10, return_when=FIRST_COMPLETED)

Bridging to asyncio

If you have a sync function and need to call it from an async context without blocking the event loop:

import asyncio

async def main():
    loop = asyncio.get_running_loop()

    # Run sync function in default thread pool
    result = await loop.run_in_executor(None, sync_function, arg1)

    # Or with a custom pool
    with ThreadPoolExecutor() as pool:
        result = await loop.run_in_executor(pool, sync_function, arg1)

In Python 3.9+, asyncio.to_thread(fn, *args) is shorthand for run_in_executor(None, fn, *args).

Interview angle

  • “How would you make 100 HTTP requests in parallel?” → ThreadPoolExecutor with as_completed, or asyncio.gather with httpx.
  • “Why use ProcessPoolExecutor instead of threads for CPU-bound work?” → GIL serializes Python execution; processes have separate interpreters.
  • “How do you call a blocking function from async code?” → loop.run_in_executor / asyncio.to_thread.
  • “What can’t you submit to ProcessPoolExecutor?” → Unpicklable callables (lambdas, methods of local classes).