Async optimization — getting the most out of asyncio
asyncio is great for I/O-bound concurrency but easy to misuse. Most “asyncio is slow” bugs are sync code blocking the event loop.
The core rule: never block the event loop
The event loop runs one coroutine at a time. If a coroutine doesn’t await, it monopolizes the loop. Blocking calls (file I/O without aiofiles, time.sleep, requests.get, CPU-heavy computation, locked database calls) freeze every other coroutine.
Detect blocking with asyncio.get_event_loop().slow_callback_duration
import asyncio
loop = asyncio.get_event_loop()
loop.slow_callback_duration = 0.1 # warn if any callback runs >100ms
Or in debug mode:
PYTHONASYNCIODEBUG=1 python my_app.py
asyncio logs callbacks that took longer than slow_callback_duration and warns about un-awaited coroutines.
Run blocking code in a thread
import asyncio
# Sync function from a library that doesn't have async support
def compute(x):
return slow_native_thing(x)
async def main():
# 3.9+: asyncio.to_thread runs sync function in a default ThreadPool
result = await asyncio.to_thread(compute, 42)
For CPU-bound work, use a process pool:
import asyncio
from concurrent.futures import ProcessPoolExecutor
async def main():
loop = asyncio.get_running_loop()
with ProcessPoolExecutor() as pool:
result = await loop.run_in_executor(pool, heavy_cpu_work, data)
Prefer async libraries for I/O
| Sync | Async equivalent |
|---|---|
requests |
httpx, aiohttp |
psycopg2 |
asyncpg, psycopg3 (sync+async) |
redis-py (sync) |
redis-py asyncio.Redis, aioredis |
boto3 |
aioboto3 |
pymongo |
motor |
| File I/O | aiofiles (or to_thread) |
Don’t pretend a sync library is async by wrapping it in a coroutine. Use the actual async client or run sync calls in to_thread.
Concurrency primitives
asyncio.gather — run N coroutines concurrently
results = await asyncio.gather(
fetch("/a"),
fetch("/b"),
fetch("/c"),
)
By default, if one task raises, all others continue but gather re-raises the first exception. Use return_exceptions=True to get exceptions in the result list instead.
asyncio.TaskGroup (3.11+) — structured concurrency
Cleaner than gather. If any child fails, all others are cancelled:
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(fetch("/a"))
t2 = tg.create_task(fetch("/b"))
# both done after the with-block
results = (t1.result(), t2.result())
Prefer TaskGroup over gather for new code. Cancellation propagation is cleaner.
asyncio.Semaphore — limit concurrency
sem = asyncio.Semaphore(10) # at most 10 concurrent fetches
async def bounded_fetch(url):
async with sem:
return await fetch(url)
await asyncio.gather(*[bounded_fetch(u) for u in 1000_urls])
Don’t fire 1000 concurrent HTTP requests — you’ll OOM, hit rate limits, or DoS the target.
Common performance traps
1. Awaiting in a tight loop instead of gathering
# slow — sequential
for url in urls:
await fetch(url)
# fast — concurrent
await asyncio.gather(*(fetch(u) for u in urls))
2. CPU work in async functions
Even small CPU work (parsing 1MB JSON, computing a hash) blocks. Use to_thread or a process pool for any non-trivial computation.
3. Synchronous logging handlers
Default logging.FileHandler writes are synchronous and can block. Under heavy logging, use QueueHandler + QueueListener to push writes to a thread.
4. Forgetting to await
async def f():
return 42
result = f() # coroutine, not awaited
result = await f() #
Python warns at exit about un-awaited coroutines. See tricky_questions/09_async_def_returns_coroutine.md.
5. Using asyncio.sleep(0) to yield
This works but is a code smell. If you need to yield to other tasks, structure the code so awaiting on real I/O does the yielding. await asyncio.sleep(0) is sometimes used to break up long synchronous chunks but is rare in production code.
6. Mixing event loops
Don’t run asyncio.run from inside another async function — it creates a new event loop. Use await main() directly. asyncio.run is for the top-level entry only.
Tools
aiomonitor— attach a console to a running asyncio app to inspect tasksaiodebug— slow-callback warnings, deadlock detectionpy-spy— sampling profiler that works on async code
Interview angle
- “Why doesn’t
asyncio.gathergive me real parallelism for CPU work?” (One event loop, one thread; CPU work blocks. Need processes.) - “How would you call a sync function from async code without blocking?” (
asyncio.to_threadorloop.run_in_executor.) - “Difference between
gatherandTaskGroup?” (TaskGroup has structured cancellation: child failure cancels siblings.) - “How would you limit to 10 concurrent HTTP requests in a script that needs to fetch 1000 URLs?” (
Semaphore(10)+gather.)