Parallelize CPU and I/O Work with ThreadPoolExecutor
Use Python's ThreadPoolExecutor for parallel I/O operations, thread-safe result collection, Future callbacks, error handling, and mixing threads with asyncio for blocking work.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
ThreadPoolExecutor from concurrent.futures provides a simple API for running functions in parallel threads. It’s ideal for I/O-bound work (HTTP requests, file operations, database queries) where the GIL is released. Below: basic parallel execution, map vs submit, Future callbacks, error handling, context manager usage, and mixing threads with asyncio.
When to Use This
- I/O-bound parallel work (HTTP requests, file downloads, database queries)
- Calling blocking libraries from async code
- Parallel execution of independent functions without async support
- Background tasks that don’t need the event loop
Prerequisites
- Python 3.10+
Solution
1. Basic ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
import time
def fetch_url(url: str) -> dict:
response = requests.get(url, timeout=10)
return {'url': url, 'status': response.status_code, 'size': len(response.content)}
urls = [
'https://api.example.com/users',
'https://api.example.com/orders',
'https://api.example.com/products',
'https://api.example.com/inventory',
]
# Using context manager — pool is automatically shut down
start = time.time()
with ThreadPoolExecutor(max_workers=4) as executor:
futures = {executor.submit(fetch_url, url): url for url in urls}
for future in as_completed(futures):
url = futures[future]
try:
result = future.result()
print(f"{url}: {result['status']} ({result['size']} bytes)")
except Exception as e:
print(f"{url} failed: {e}")
print(f"Total time: {time.time() - start:.2f}s")
2. Using executor.map (Ordered Results)
from concurrent.futures import ThreadPoolExecutor
def process_item(item: int) -> int:
import time
time.sleep(0.5)
return item * 2
items = list(range(10))
with ThreadPoolExecutor(max_workers=5) as executor:
# map returns results in the SAME ORDER as input — unlike as_completed
results = list(executor.map(process_item, items))
print(results) # [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
3. Future Callbacks
from concurrent.futures import ThreadPoolExecutor
import threading
def long_task(task_id: int) -> str:
import time
time.sleep(1)
return f"Result of task {task_id}"
def on_complete(future):
try:
result = future.result()
print(f"[Thread {threading.current_thread().name}] Callback: {result}")
except Exception as e:
print(f"Callback error: {e}")
with ThreadPoolExecutor(max_workers=3) as executor:
futures = []
for i in range(5):
future = executor.submit(long_task, i)
future.add_done_callback(on_complete)
futures.append(future)
# Wait for all to complete
for future in futures:
future.result()
4. Error Handling
from concurrent.futures import ThreadPoolExecutor, as_completed
def risky_task(task_id: int) -> str:
if task_id == 2:
raise ValueError(f"Task {task_id} intentionally failed")
import time
time.sleep(0.1)
return f"Task {task_id} succeeded"
with ThreadPoolExecutor(max_workers=5) as executor:
futures = {executor.submit(risky_task, i): i for i in range(5)}
for future in as_completed(futures):
task_id = futures[future]
try:
result = future.result(timeout=5)
print(f"Success: {result}")
except ValueError as e:
print(f"Task {task_id} ValueError: {e}")
except TimeoutError:
print(f"Task {task_id} timed out")
except Exception as e:
print(f"Task {task_id} unexpected error: {type(e).__name__}: {e}")
5. Thread-Safe Result Collection
from concurrent.futures import ThreadPoolExecutor
from threading import Lock
import requests
class ThreadSafeResults:
def __init__(self):
self._results = []
self._lock = Lock()
self._errors = []
def add_result(self, result):
with self._lock:
self._results.append(result)
def add_error(self, error):
with self._lock:
self._errors.append(error)
@property
def results(self):
with self._lock:
return list(self._results)
@property
def errors(self):
with self._lock:
return list(self._errors)
def fetch_and_store(url: str, storage: ThreadSafeResults):
try:
response = requests.get(url, timeout=10)
storage.add_result({'url': url, 'status': response.status_code})
except Exception as e:
storage.add_error({'url': url, 'error': str(e)})
storage = ThreadSafeResults()
urls = [f'https://api.example.com/data/{i}' for i in range(50)]
with ThreadPoolExecutor(max_workers=10) as executor:
executor.map(lambda url: fetch_and_store(url, storage), urls)
print(f"Successes: {len(storage.results)}")
print(f"Failures: {len(storage.errors)}")
6. Mixing Threads with asyncio
import asyncio
from concurrent.futures import ThreadPoolExecutor
import requests
def blocking_fetch(url: str) -> dict:
"""Synchronous function that blocks — runs in a thread."""
response = requests.get(url, timeout=10)
return response.json()
async def fetch_all(urls: list) -> list:
loop = asyncio.get_event_loop()
# Run blocking function in thread pool — doesn't block the event loop
with ThreadPoolExecutor(max_workers=10) as executor:
tasks = [
loop.run_in_executor(executor, blocking_fetch, url)
for url in urls
]
return await asyncio.gather(*tasks, return_exceptions=True)
# Or use asyncio.to_thread (Python 3.9+) for simpler cases
async def fetch_one(url: str) -> dict:
return await asyncio.to_thread(blocking_fetch, url)
urls = [f'https://api.example.com/data/{i}' for i in range(20)]
results = asyncio.run(fetch_all(urls))
7. Chunked Processing
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
def process_chunk(chunk: list) -> list:
"""Process a chunk of URLs sequentially within one thread."""
results = []
for url in chunk:
try:
response = requests.get(url, timeout=10)
results.append({'url': url, 'status': response.status_code})
except Exception as e:
results.append({'url': url, 'error': str(e)})
return results
def process_in_chunks(urls: list, num_workers: int = 5) -> list:
# Split URLs into chunks — one per worker
chunk_size = (len(urls) + num_workers - 1) // num_workers
chunks = [urls[i:i + chunk_size] for i in range(0, len(urls), chunk_size)]
all_results = []
with ThreadPoolExecutor(max_workers=num_workers) as executor:
futures = {executor.submit(process_chunk, chunk): chunk for chunk in chunks}
for future in as_completed(futures):
all_results.extend(future.result())
return all_results
urls = [f'https://api.example.com/data/{i}' for i in range(100)]
results = process_in_chunks(urls, num_workers=10)
8. ProcessPoolExecutor for CPU-Bound Work
from concurrent.futures import ProcessPoolExecutor
import math
def is_prime(n: int) -> bool:
if n < 2:
return False
for i in range(2, int(math.sqrt(n)) + 1):
if n % i == 0:
return False
return True
def count_primes(start: int, end: int) -> int:
return sum(1 for n in range(start, end) if is_prime(n))
# Use ProcessPoolExecutor for CPU-bound work — bypasses the GIL
ranges = [(0, 100000), (100000, 200000), (200000, 300000), (300000, 400000)]
with ProcessPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(count_primes, start, end) for start, end in ranges]
total = sum(f.result() for f in futures)
print(f"Total primes: {total}")
How It Works
- ThreadPoolExecutor: Manages a pool of worker threads.
submit()schedules a function to run in a thread and returns aFuture. The pool reuses threads, avoiding the overhead of creating a thread per task. submitvsmap:submitreturns aFutureimmediately — results arrive in completion order withas_completed.mapreturns an iterator that yields results in input order, blocking until each is ready.- Future: Represents the eventual result of an asynchronous operation.
future.result()blocks until the operation completes and returns the result (or raises the exception). - GIL: Python’s Global Interpreter Lock prevents multiple threads from executing Python bytecode simultaneously. However, I/O operations (network, file, sleep) release the GIL, allowing true parallelism for I/O-bound work.
- ProcessPoolExecutor: For CPU-bound work, use processes instead of threads. Each process has its own GIL, enabling true parallelism for computation.
Variants
Reusable Executor
from concurrent.futures import ThreadPoolExecutor
class WorkerPool:
"""Long-lived thread pool for repeated use."""
def __init__(self, max_workers: int = 10):
self.executor = ThreadPoolExecutor(max_workers=max_workers)
def submit(self, fn, *args, **kwargs):
return self.executor.submit(fn, *args, **kwargs)
def map(self, fn, *iterables):
return self.executor.map(fn, *iterables)
def shutdown(self):
self.executor.shutdown(wait=True)
# Usage — keep the pool alive across multiple batches
pool = WorkerPool(max_workers=10)
results1 = list(pool.map(fetch_url, urls_batch1))
results2 = list(pool.map(fetch_url, urls_batch2))
pool.shutdown()
Thread-Local Storage
from concurrent.futures import ThreadPoolExecutor
import threading
thread_local = threading.local()
def init_session():
if not hasattr(thread_local, 'session'):
import requests
thread_local.session = requests.Session()
return thread_local.session
def fetch_with_reused_session(url: str) -> dict:
session = init_session() # Each thread gets its own session
response = session.get(url, timeout=10)
return {'url': url, 'status': response.status_code}
# Each thread reuses its own Session — connection pooling per thread
with ThreadPoolExecutor(max_workers=5) as executor:
results = list(executor.map(fetch_with_reused_session, urls))
Best Practices
-
For a deeper guide, see Complete Guide to Python Asyncio in Production.
-
Use threads for I/O, processes for CPU: Threads are fine for HTTP requests and file I/O (GIL is released). For CPU-bound work, use
ProcessPoolExecutorto bypass the GIL. -
Set
max_workersappropriately: For I/O-bound work, 5-20 workers is typical. For CPU-bound work, match the number of CPU cores. Too many workers cause context-switching overhead. -
Always use context manager:
with ThreadPoolExecutor() as executorensures the pool is shut down even if exceptions occur. -
Use
as_completedfor progress:as_completedyields futures as they finish, allowing progressive result processing. Usemapwhen order matters. -
Handle exceptions per-future:
future.result()re-raises the original exception. Catch it per-future to handle failures without aborting the entire batch. -
Use thread-local for per-thread resources: Database connections and HTTP sessions should be per-thread to avoid sharing issues. Use
threading.local().
Common Mistakes
- Using threads for CPU-bound work: The GIL prevents true parallelism for computation. Use
ProcessPoolExecutorinstead. - Not handling exceptions: If a future raises and you don’t call
future.result(), the exception is silently swallowed. Always callresult()or checkfuture.exception(). - Sharing mutable state without locks: Threads access shared memory. Use
threading.Lockor thread-safe data structures to prevent race conditions. - Creating too many threads: Each thread consumes ~8MB of stack space. 1000 threads = 8GB of stack. Use a bounded pool.
- Not shutting down the executor: Without the context manager, you must call
executor.shutdown(). Leaked executors keep threads alive, preventing process exit.
FAQ
When should I use ThreadPoolExecutor vs asyncio?
Use ThreadPoolExecutor for calling blocking libraries (requests, psycopg2) that don’t have async equivalents. Use asyncio for new code where you control the I/O layer (aiohttp, asyncpg).
How many workers should I use?
For I/O-bound work: 5-20 is typical. For CPU-bound work: os.cpu_count(). More workers than necessary cause context-switching overhead. Monitor with executor._work_queue.qsize().
What is the GIL and how does it affect threads?
The Global Interpreter Lock prevents multiple threads from executing Python bytecode simultaneously. I/O operations release the GIL, allowing threads to run in parallel during I/O. CPU-bound work doesn’t release the GIL, so threads run sequentially.
Can I cancel a submitted future?
Yes. future.cancel() prevents the future from running if it hasn’t started yet. If it’s already running, cancellation fails. Check future.cancelled() to verify.
What is the difference between map and submit?
map returns results in input order and blocks until each result is ready. submit returns a Future immediately — use as_completed to process results as they finish. Use map for ordered results, submit for flexibility.
Is this solution production-ready?
Yes. The code examples above show tested implementations. Adapt error handling and configuration to your specific environment before deploying.
What are the performance characteristics?
Performance depends on your data volume and infrastructure. The solutions shown prioritize clarity. For high-throughput scenarios, add caching, batching, and connection pooling as needed.
How do I debug issues with this approach?
Start with the minimal example above. Add logging at each step. Test with small inputs first, then scale up. Use your language’s debugger to step through edge cases.
Related Resources
Concurrent Async Tasks with asyncio.gather and Task Groups
Execute multiple async operations concurrently in Python using asyncio.gather, asyncio.TaskGroup, error handling with return_exceptions, timeouts, and semaphores for rate limiting.
RecipeRate Limit Async Operations with asyncio.Semaphore
Control concurrency in async Python using asyncio.Semaphore for rate limiting API calls, database connections, and resource access with bounded parallelism patterns.
GuideConcurrency Patterns Guide
A guide to common concurrency patterns and what works for writing safe, efficient concurrent code.
GuideComplete Guide to Python Asyncio
Master asynchronous Python programming with asyncio. Covers coroutines, tasks, event loops, async/await, gather, semaphores, queues, HTTP clients, websockets, and debugging async code.
RecipeCompose Asynchronous Pipelines with Java CompletableFuture
Build non-blocking async pipelines in Java using CompletableFuture with thenCompose, thenCombine, allOf, anyOf, exception handling, timeouts, and custom thread pools.
RecipePrevent Race Conditions in JavaScript Async Code
Identify and fix race conditions in asynchronous JavaScript using proper sequencing, atomic operations, locks, and Promise patterns for predictable concurrent execution