Frontend Labs
⋯

29. asyncio: the event loop, tasks, timeouts, and cancellation

29.1 Why async exists

A thread per connection costs memory (a stack per thread) and kernel scheduling work. With 10 000 mostly idle connections, most threads just wait. An event loop instead uses one thread and asks the kernel which sockets are ready (epoll on Linux, kqueue on macOS; see OS Ch 78), then resumes only the code waiting on those sockets.

async def functions are coroutines: functions that can pause at an await and let the loop run something else. They are built on the same suspend-and-resume machinery as generators (Ch 13.5).

import asyncio
import time

async def fetch(name: str, delay: float) -> str:
    await asyncio.sleep(delay)          # yields to the event loop while "waiting on I/O"
    return f"{name} done"

async def main() -> None:
    t = time.perf_counter()
    results = await asyncio.gather(fetch("a", 1), fetch("b", 1), fetch("c", 1))
    print(results, f"{time.perf_counter() - t:.1f}s")   # ~1.0s, not 3s

asyncio.run(main())

Calling fetch("a", 1) does not run it; it creates a coroutine object. Only await, a task, or asyncio.run runs it. Forgetting await gives RuntimeWarning: coroutine 'fetch' was never awaited, and the work never happens.

29.2 Structured concurrency with TaskGroup

import asyncio

async def probe(host: str) -> str:
    await asyncio.sleep(0.1)
    if host == "db":
        raise ConnectionError(f"{host}: refused")
    return f"{host}: ok"

async def main() -> None:
    try:
        async with asyncio.TaskGroup() as tg:          # 3.11+
            tasks = [tg.create_task(probe(h)) for h in ["web", "db", "cache"]]
        print([t.result() for t in tasks])
    except* ConnectionError as eg:                     # failures arrive as an ExceptionGroup
        for e in eg.exceptions:
            print("failed:", e)

asyncio.run(main())

TaskGroup guarantees that when the async with block exits, every task it started has finished. If one fails, the others are cancelled and the errors are raised together. Prefer it over bare create_task, which lets tasks outlive the code that started them (and lets their exceptions disappear if nobody awaits them).

29.3 Timeouts and cancellation

import asyncio

async def slow_query() -> str:
    try:
        await asyncio.sleep(10)
        return "rows"
    except asyncio.CancelledError:
        print("query cancelled: releasing the connection")
        raise                                   # always re-raise CancelledError

async def main() -> None:
    try:
        async with asyncio.timeout(0.5):        # 3.11+
            await slow_query()
    except TimeoutError:
        print("timed out")

asyncio.run(main())

Cancellation is delivered as a CancelledError raised at the await where the task is paused. Clean up in except/finally, then re-raise. Swallowing CancelledError breaks timeouts and shutdown. Every network call in async code needs a timeout; the HTTPS guide Ch 21 explains why a missing timeout eventually takes the whole service down.

29.4 Limiting concurrency, and queues

import asyncio
import random

async def download(i: int, sem: asyncio.Semaphore) -> int:
    async with sem:                               # at most N inside at once
        await asyncio.sleep(random.uniform(0.05, 0.2))
        return i

async def producer(q: asyncio.Queue[int]) -> None:
    for i in range(10):
        await q.put(i)                            # blocks when the queue is full: backpressure
    await q.put(-1)

async def consumer(q: asyncio.Queue[int]) -> None:
    while (item := await q.get()) != -1:
        print("processed", item)

async def main() -> None:
    sem = asyncio.Semaphore(5)
    got = await asyncio.gather(*(download(i, sem) for i in range(20)))
    print(len(got), "downloads")
    q: asyncio.Queue[int] = asyncio.Queue(maxsize=3)
    async with asyncio.TaskGroup() as tg:
        tg.create_task(producer(q))
        tg.create_task(consumer(q))

asyncio.run(main())

A semaphore is how you avoid opening 10 000 connections to a server that allows 100. A bounded queue gives backpressure: a fast producer waits for a slow consumer instead of filling memory.

29.5 The one rule: never block the event loop

Everything in an event loop shares one thread. A blocking call (time.sleep, requests.get, a CPU-heavy loop, a synchronous database driver) freezes every connection until it returns.

import asyncio
import hashlib
import time

def blocking_hash(data: bytes) -> str:           # CPU-bound or blocking library call
    time.sleep(0.2)
    return hashlib.sha256(data).hexdigest()[:12]

async def main() -> None:
    # Run blocking work in a thread so the loop stays responsive:
    digest = await asyncio.to_thread(blocking_hash, b"model.bin")
    print(digest)

asyncio.run(main())

Use async libraries in async code (httpx.AsyncClient, asyncpg, aiofiles), asyncio.to_thread for blocking calls, and a process pool (loop.run_in_executor(process_pool, fn)) for CPU-bound work. Run with PYTHONASYNCIODEBUG=1 (or asyncio.run(main(), debug=True)) during development: it logs every callback that blocks the loop for more than 100 ms.

Across the series: Go hides the event loop inside the runtime, so blocking code is fine in a goroutine (OS Ch 78). Rust and Python make it explicit with async/await, and both suffer from the same "function colouring": async functions can call sync ones, but not the other way round without a bridge.


Generated from real-life-python-guide.md · 8 Oct 2026 16:36