Steven's Knowledge

Async & Concurrency

Python's async model in depth -- async/await, the asyncio event loop, tasks vs futures, cancellation, and exception groups

Async & Concurrency

Python's async model is single-threaded and event-loop-based, and it excels at I/O-bound work. This page goes beyond async/await syntax into the actual objects -- coroutines, tasks, and futures -- plus cooperative cancellation, structured concurrency with TaskGroup, and the exception groups that handle many failures at once.

Async/Await

Python's async model is single-threaded, event-loop-based, similar to Node.js. It excels at I/O-bound work:

import asyncio
import httpx

async def fetch_user_data(user_id: str) -> dict:
    async with httpx.AsyncClient() as client:
        # These run concurrently, not sequentially
        profile, orders, preferences = await asyncio.gather(
            client.get(f"/api/users/{user_id}/profile"),
            client.get(f"/api/users/{user_id}/orders"),
            client.get(f"/api/users/{user_id}/preferences"),
        )
    return {
        "profile": profile.json(),
        "orders": orders.json(),
        "preferences": preferences.json(),
    }

# TaskGroup (Python 3.11+) -- structured concurrency
async def fetch_with_error_handling(urls: list[str]) -> list[str]:
    results = []
    async with asyncio.TaskGroup() as tg:
        for url in urls:
            tg.create_task(fetch_one(url))
    # If any task raises, all others are cancelled
    return results

# Semaphore for rate limiting
async def fetch_many(urls: list[str], max_concurrent: int = 10) -> list[str]:
    semaphore = asyncio.Semaphore(max_concurrent)

    async def fetch_one(url: str) -> str:
        async with semaphore:
            async with httpx.AsyncClient() as client:
                resp = await client.get(url)
                return resp.text

    return await asyncio.gather(*[fetch_one(url) for url in urls])

The asyncio Event Loop Up Close

"It's like Node" is a useful first approximation, but experts know the actual objects. A coroutine is a paused function; a Task wraps a coroutine and schedules it on the loop; a Future is a low-level "result that will arrive". await yields control back to the loop until the awaited thing is ready.

import asyncio

# create_task schedules immediately; the coroutine starts running at the next await.
# Calling a coroutine function WITHOUT awaiting does nothing but emit a warning:
async def main():
    coro = work()                 # not running yet -- just a coroutine object
    task = asyncio.create_task(coro)   # NOW it's scheduled
    await task

Cancellation is cooperative: cancelling a task injects a CancelledError at its next await. Never swallow it blindly -- catching Exception accidentally eats cancellation (in 3.8+ CancelledError derives from BaseException precisely to avoid this):

async def worker():
    try:
        await long_running()
    except asyncio.CancelledError:
        await cleanup()           # do cleanup...
        raise                     # ...then ALWAYS re-raise, or cancellation breaks

# shield() protects a critical section from cancellation propagating inward:
await asyncio.shield(commit_transaction())

# Timeouts (3.11+): the clean, composable way to bound any await
async with asyncio.timeout(5.0):
    await slow_operation()

Blocking work must leave the loop thread, or it freezes every coroutine. Offload CPU-bound or legacy-blocking calls to an executor:

loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, blocking_db_call, arg)  # thread pool
# For CPU-bound, pass a ProcessPoolExecutor instead.

contextvars is how async code carries per-request state (request id, user, trace id) without threading it through every call -- the async-safe replacement for thread-locals:

from contextvars import ContextVar
request_id: ContextVar[str] = ContextVar("request_id")
request_id.set("abc-123")     # each task sees its own value, even concurrently

Exception Groups and except*

Structured concurrency raises a new problem: when several tasks fail at once, which exception wins? Python 3.11 answers with ExceptionGroup and the except* syntax, which handles multiple exceptions from a single group:

async def fetch_all(urls: list[str]):
    async with asyncio.TaskGroup() as tg:   # collects ALL failures, not just the first
        for url in urls:
            tg.create_task(fetch(url))
    # If two tasks raise, the TaskGroup raises an ExceptionGroup containing both.

try:
    await fetch_all(urls)
except* TimeoutError as eg:        # handles every TimeoutError in the group
    log.warning("timeouts: %d", len(eg.exceptions))
except* ConnectionError as eg:     # and separately, every ConnectionError
    log.error("connection failures: %d", len(eg.exceptions))

except* may run more than one of its branches (unlike except, where exactly one wins), because a single group can contain several exception types. You can also build and raise groups yourself for validation that should report all problems at once rather than stopping at the first:

errors = [ValueError(f"row {i}") for i in bad_rows]
if errors:
    raise ExceptionGroup("validation failed", errors)

add_note() (3.11+) attaches context to any exception without wrapping it -- useful for adding the failing record id as the exception propagates.

On this page