Coroutines, the event loop, tasks, and writing correct async code
Async Python is now mainstream — FastAPI, SQLAlchemy async, Redis async clients, and most modern ML serving frameworks are async by default. Understanding how the event loop, coroutines, and tasks actually work is essential for writing correct async code. Getting it wrong produces bugs that are extremely hard to debug: blocking the event loop freezes the entire server, not just one request.
Backend and ML engineering increasingly runs on async frameworks. Knowing the difference between a coroutine and a task, what await actually does, and how to offload CPU work without blocking the loop is essential for writing correct async code.
A coroutine is a function that can pause and resume. await pauses the current coroutine and gives control back to the event loop, which runs other ready coroutines. When the awaited operation completes, the event loop resumes the original coroutine. One thread, many coroutines, no OS context switching.
Think of the event loop as a restaurant manager: when a waiter (coroutine) is waiting for the kitchen (IO), the manager assigns them to another table (another coroutine). No waiter just stands idle — they all cooperate to keep things moving.
An async def function returns a coroutine object — it doesn't execute until awaited. asyncio.run starts the event loop and drives the coroutine to completion.
An async function called without await returns a coroutine object — it does not run. This is a common mistake: forgetting await means the operation never executes and no error is raised.
Two ways to run multiple coroutines concurrently — both start them in parallel, but differ in when you block to collect results and how much control you have in between.
Use gather when all coroutines are ready upfront and you want all results at once. Use create_task to schedule a coroutine immediately and await it later — giving you control over when you collect the result.
Async operations can hang indefinitely. Wrapping with a timeout guarantees forward progress; cancellation stops a running task early and triggers its cleanup via CancelledError.
wait_for wraps any coroutine with a hard deadline — raises TimeoutError if it doesn't finish in time. Always await a task after cancelling; the event loop needs to process the CancelledError before cleanup is complete.
Resources that need async setup or teardown (HTTP sessions, database connections) use async with. Streams that produce values over IO use async for — consuming one item at a time without loading everything into memory.
Use async with for resources that need async setup or teardown — HTTP sessions, database connections, file handles over async APIs. Use async for to consume data streams one item at a time without loading everything into memory.
Any blocking call inside an async function freezes the entire event loop — every other request stalls for its duration. CPU work and sync-blocking calls must be offloaded to a thread or process pool.
Any synchronous blocking call inside an async function (file reads, time.sleep, CPU-bound computation, sync database drivers) freezes the entire event loop for its duration. Every other request stalls. Use asyncio.to_thread or run_in_executor to move it off the loop.