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?” →
ThreadPoolExecutorwithas_completed, orasyncio.gatherwith httpx. - “Why use
ProcessPoolExecutorinstead 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).