Overview
asyncio provides concurrency within a single thread by running many coroutines cooperatively on an event loop, each coroutine runs until it hits an await on I/O, voluntarily yields control, and the event loop runs something else while that I/O completes. This works well specifically for I/O-bound workloads with high concurrency (many simultaneous network connections), because the CPU is never actually idle waiting on any single one of them. It does nothing for CPU-bound work, and it has one sharp, easy-to-hit failure mode: any blocking (non-await-yielding) call anywhere in the path freezes the entire event loop, silently serializing everything that was supposed to run concurrently.
Quick Reference
| Concept | What it means |
|---|---|
| Coroutine | A function defined with async def, resumable at each await |
| Event loop | Schedules and runs coroutines, resuming them when their awaited I/O completes |
await | Yields control back to the event loop until the awaited operation completes |
| Task | A coroutine scheduled to run concurrently via asyncio.create_task |
| Blocking call | Any synchronous I/O/CPU work that doesn't yield, freezes the whole loop |
Syntax
import asyncio
async def fetch(session, url):
async with session.get(url) as response:
return await response.json()
async def main():
async with aiohttp.ClientSession() as session:
results = await asyncio.gather(
*(fetch(session, url) for url in urls)
)Examples
# A single blocking call anywhere in an async path stalls the
# entire event loop - every other concurrent task is frozen too.
async def handle_request(request):
data = requests.get(EXTERNAL_API) # blocking! [!code highlight]
return data.json()
# Fixed: use an async-compatible HTTP client instead
async def handle_request_fixed(request, session):
async with session.get(EXTERNAL_API) as response:
return await response.json()asyncio does not help CPU-bound work
A CPU-heavy coroutine blocks the event loop exactly like any other blocking call, since there's still only one thread. Use multiprocessing or a process pool for CPU-bound work, asyncio only helps when the workload is genuinely I/O-bound.
Visual Diagram
Common Mistakes
- Calling a synchronous, blocking library (a non-async HTTP client,
time.sleep, synchronous file I/O) inside an async function, silently freezing the entire event loop for its duration. - Reaching for asyncio to speed up CPU-bound work, which it cannot do, CPU-bound work needs
multiprocessing, notasyncio. - Forgetting
awaiton a coroutine call, which creates a coroutine object without ever running it, a silent no-op bug, not an error. - Mixing sync and async code paths without a clear boundary, making it unclear which parts of a codebase are safe to call blocking operations from.
Performance
- asyncio's benefit scales with I/O concurrency, a program making one request at a time gets little to no benefit over synchronous code, while one making thousands of concurrent requests can see dramatic throughput improvements.
- Context-switching between coroutines is far cheaper than OS thread context-switching, which is part of why asyncio can handle far more concurrent I/O operations than a thread-per-connection model at the same resource cost.
- A single blocking call in a hot path doesn't just slow down its own coroutine; it stalls every other concurrently scheduled coroutine, making its performance impact disproportionate to its own cost.
Best Practices
- Use async-compatible libraries end-to-end for any I/O in an async codebase, a synchronous library anywhere in the path defeats the purpose.
- Reach for asyncio specifically for I/O-bound, high-concurrency workloads; use
multiprocessingfor CPU-bound work instead. - Use
asyncio.gather(orTaskGroupin newer Python versions) to run independent I/O operations concurrently rather than awaiting them one at a time in sequence. - Keep a clear boundary between sync and async code, and run genuinely blocking operations that can't be made async in a thread/process pool executor instead of directly in a coroutine.