Async Python

Coroutines, the event loop, tasks, and writing correct async code

Why

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.

Intuition

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.

Explanation
1. Coroutines and the event loop

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.

import asyncio async def greet(name, delay): await asyncio.sleep(delay) # yields control to the event loop print(f"Hello, {name}") # resumes here after delay # asyncio.run creates and tears down the event loop asyncio.run(greet("Alice", 1)) # Calling an async def returns a coroutine OBJECT — not the result coro = greet("Bob", 1) # nothing runs yet await coro # NOW it runs (inside an async context) # Two coroutines run concurrently — total time ~1s, not ~2s async def main(): await asyncio.gather( greet("Alice", 1), greet("Bob", 1), )

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.

2. gather vs create_task

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.

# asyncio.gather — run multiple coroutines concurrently, collect results results = await asyncio.gather( fetch(session, url1), fetch(session, url2), fetch(session, url3), return_exceptions=True, # exceptions become results, not crashes ) # asyncio.create_task — schedule immediately, don't wait yet # Useful when you want to start tasks and do other work first task1 = asyncio.create_task(process_chunk(chunk1)) task2 = asyncio.create_task(process_chunk(chunk2)) # ... do something else ... result1 = await task1 result2 = await task2 # Key difference: # gather runs coroutines starting from the first await # create_task schedules immediately — the task starts right now

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.

3. Timeouts and cancellation

Async operations can hang indefinitely. Wrapping with a timeout guarantees forward progress; cancellation stops a running task early and triggers its cleanup via CancelledError.

# Timeout — raises TimeoutError if coroutine takes too long try: result = await asyncio.wait_for(fetch(url), timeout=5.0) except asyncio.TimeoutError: result = fallback_value # Cancelling a task task = asyncio.create_task(long_running()) await asyncio.sleep(1) task.cancel() try: await task except asyncio.CancelledError: pass # handle cleanup if needed # asyncio.timeout (Python 3.11+) — cleaner context manager form async with asyncio.timeout(5.0): result = await fetch(url)

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.

4. Async context managers and iterators

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.

# Async context manager — __aenter__ / __aexit__ async with aiohttp.ClientSession() as session: async with session.get(url) as resp: data = await resp.json() # Async iterator — __aiter__ / __anext__ async for record in db.execute("SELECT * FROM events"): process(record) # Writing your own async context manager from contextlib import asynccontextmanager @asynccontextmanager async def managed_connection(dsn): conn = await asyncpg.connect(dsn) try: yield conn finally: await conn.close()

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.

5. Running CPU-bound work without blocking the loop

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.

# Blocking call inside async — WRONG — freezes the entire event loop async def bad(): result = cpu_heavy() # blocks all other coroutines while running # Correct — run in a thread/process pool, await the result import asyncio async def good(): loop = asyncio.get_event_loop() # run_in_executor uses ThreadPoolExecutor by default result = await loop.run_in_executor(None, cpu_heavy, arg) # Or use asyncio.to_thread (Python 3.9+) — cleaner async def better(): result = await asyncio.to_thread(cpu_heavy, arg)

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.