Python concurrency confuses people because the language offers three overlapping tools—threads, asyncio, and multiprocessing—and each one wins in a different situation. Pick the wrong model and you will either block the event loop, burn CPU without speedup, or drown in complexity.
This guide maps real backend tasks to the right approach.
The GIL in one paragraph
CPython's Global Interpreter Lock (GIL) allows only one thread to execute Python bytecode at a time in a single process. That means threads do not parallelize CPU-bound Python code across cores. They still help when work waits on I/O (network, disk, database) because threads release the GIL while blocked.
Asyncio runs concurrent tasks in one thread with cooperative scheduling—great for many I/O waits, useless for heavy numeric loops unless you offload them.
Multiprocessing spawns separate Python interpreters—true parallelism for CPU work, at the cost of higher memory and trickier data sharing.
Keep that triangle in mind for every decision below.
When threads make sense
Use threading or concurrent.futures.ThreadPoolExecutor when:
- You call blocking I/O libraries (many database drivers,
requests, file reads). - You have a small to moderate number of concurrent tasks.
- You want simpler mental model than asyncio for a script or batch job.
Example: fetching several HTTP endpoints:
from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
URLS = [
"https://api.example.com/users/1",
"https://api.example.com/users/2",
"https://api.example.com/users/3",
]
def fetch(url: str) -> dict:
resp = requests.get(url, timeout=10)
resp.raise_for_status()
return resp.json()
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(fetch, url): url for url in URLS}
for future in as_completed(futures):
data = future.result()
print(data["id"])
Pitfall: sharing mutable state without locks. Prefer immutable results or queues.
When asyncio is the right default for services
FastAPI, Starlette, aiohttp, and modern DB drivers (asyncpg, motor) are built around async/await. Asyncio shines when you handle thousands of idle connections—websockets, microservices gateways, LLM streaming APIs.
Core pattern:
import asyncio
import httpx
async def fetch(client: httpx.AsyncClient, url: str) -> dict:
resp = await client.get(url, timeout=10.0)
resp.raise_for_status()
return resp.json()
async def main():
async with httpx.AsyncClient() as client:
tasks = [fetch(client, u) for u in URLS]
results = await asyncio.gather(*tasks)
return results
asyncio.run(main())
Do not block the event loop
Calling time.sleep(), synchronous requests.get(), or heavy pandas work inside async def stalls every other coroutine. Options:
- Use native async libraries.
- Wrap blocking code with
asyncio.to_thread()(3.9+) for occasional calls. - Offload CPU work to a process pool.
When to reach for multiprocessing
Use multiprocessing or ProcessPoolExecutor for:
- CPU-bound Python: image processing, large JSON parsing, custom compression, ML feature engineering in pure Python.
- Tasks that must bypass the GIL entirely.
from concurrent.futures import ProcessPoolExecutor
def normalize_row(row: dict) -> dict:
# CPU-heavy transformation
row["score"] = expensive_calculation(row)
return row
rows = load_rows() # list of dicts
with ProcessPoolExecutor() as pool:
cleaned = list(pool.map(normalize_row, rows, chunksize=100))
Trade-offs: higher memory (each process loads its own interpreter), serialization overhead for arguments/results, harder debugging. For numeric workloads, NumPy, pandas, and native extensions often release the GIL internally—profile before spawning processes.
Choosing among the three (quick reference)
| Workload | Best tool | Why |
|---|---|---|
| Many HTTP/API calls in a web app | asyncio | Scales connections; non-blocking I/O |
| Legacy blocking SDK in a small tool | threads | Minimal rewrite |
| CPU-heavy pure Python loop | multiprocessing | Parallel bytecode execution |
| Mixed I/O + CPU | asyncio + process pool | Keep loop responsive, offload crunch |
Common production patterns
Web server + background jobs: Run FastAPI with asyncio; push long CPU jobs to Celery/RQ workers (separate processes).
Rate-limited API client: Asyncio with a semaphore:
sem = asyncio.Semaphore(10)
async def bounded_fetch(client, url):
async with sem:
return await fetch(client, url)
Database connection pools: Size pools to your concurrency model. Async pools (asyncpg) match asyncio servers; thread pools need one connection per active thread or careful pooling.
Debugging concurrency bugs
- Heisenbugs from shared state: use thread-safe queues or pass messages, not shared lists.
- Deadlocks: lock ordering matters; prefer
asyncio.Lockin async code, avoid mixing locks across threads and coroutines carelessly. - Silent starvation: one slow coroutine without timeouts blocks progress—set timeouts on I/O.
Logging correlation IDs across tasks makes async traces readable.
Real-world scenario: API aggregator service
Imagine a service that calls five third-party APIs to build a dashboard response.
Threads approach: Simple with requests and a thread pool—good for an internal cron job that runs every five minutes with modest parallelism.
Asyncio approach: Natural fit for a FastAPI endpoint serving hundreds of concurrent users. Each upstream call awaits without blocking peers; a semaphore caps outbound rate to respect vendor limits.
Multiprocessing approach: Overkill unless one step runs heavy Python analytics on the merged JSON before responding.
If one vendor SDK is blocking-only, wrap just that call in asyncio.to_thread() rather than rewriting the entire stack overnight.
Real-world scenario: ETL batch job
A nightly job reads millions of rows, transforms them in Python, and writes results. Profile first:
- If transformation uses NumPy/pandas on large arrays, you may already get C-level speed without multiprocessing.
- If transformation is pure Python per row,
ProcessPoolExecutorwith a sensiblechunksizeamortizes process overhead. - If the bottleneck is database read/write, optimize SQL, batch inserts, and connection pooling before adding processes.
Choosing concurrency limits
There is no universal max_workers. Start conservative:
- Threads: min(32, number_of_tasks) is a common default; watch DB connection pool size.
- Asyncio: limit in-flight requests with semaphores; monitor open file descriptors.
- Processes: often
cpu_count()orcpu_count() - 1; memory per process is the hard ceiling.
Load-test with realistic payloads. Increasing concurrency past the knee of the latency curve usually increases errors (429s, pool exhaustion) without improving throughput.
FAQ
Is asyncio always faster than threads?
Not always. For a handful of blocking calls in a CLI script, threads may be simpler and equally fast. Asyncio pays off at high connection counts.
Can I mix threads and asyncio?
Yes—loop.run_in_executor() runs blocking functions in a thread pool from async code. Use sparingly.
What about multiprocessing on macOS/Windows?
Guard entry points with if __name__ == '__main__': to avoid spawn issues.
Should I use greenlets or gevent instead?
Gevent monkey-patches blocking libraries for cooperative multitasking. It can work in legacy stacks but is less idiomatic than asyncio for new Python 3.11+ services.
How do I migrate a Flask app to asyncio?
Often incrementally: run Flask with threads today; introduce async endpoints via Quart/Starlette for new routes; or keep Flask and offload async microservices for high-fan-out paths.
Does the GIL matter on AWS Lambda?
Lambda scales by concurrency (more instances), not threads within one instance. CPU-bound work still benefits from native code or splitting work across invocations.
Quick decision flowchart (text version)
- Is the bottleneck waiting on network/disk/database? → Prefer asyncio in web services, threads in scripts.
- Is the bottleneck Python loops on CPU with no native library help? → Multiprocessing or rewrite hot path in C/Rust/extension.
- Are you already on FastAPI/async stack? → Stay async; do not sprinkle threads without
to_thread. - Unsure? → Profile with
cProfileor APM flame graphs before changing architecture.
Python concurrency is not one hammer. Match the tool to whether you are waiting on I/O, serving many connections, or burning CPU—and measure before rewriting a working system.
Comments
Loading comments…