Part 5 · Advanced Python · Module 38

Async Programming with asyncio

One thread, thousands of concurrent network calls, and no locks around your data structures: that's the promise of asyncio. It delivers, but only if you understand the event loop well enough to never block it, to structure tasks so none leak, and to handle timeouts and cancellation correctly. This module builds that understanding from the loop up.

⏱ ~3 hoursPrerequisites: M37 Concurrency, M14 Iterators & Generators

The event loop mental model

In M37 you saw that threads overlap I/O waits, but they bring preemptive switching and therefore races at any instruction. asyncio takes a different approach called cooperative multitasking. All your tasks run in one thread. A task runs until it reaches an await on something that isn't ready yet (a network reply, a timer); at that point it voluntarily suspends and hands control back to the event loop. The loop then runs another task that is ready. When the reply arrives, the loop marks the waiting task ready again and resumes it exactly where it left off.

Conceptually, the loop is just this:

while tasks_exist:
    run every callback in the READY queue        # each runs until its next await
    wait for I/O or the next timer (selectors / epoll / kqueue / IOCP)
    move tasks whose I/O or timer completed to the READY queue

Compare the three ways of doing three 1-second waits. Step through each scenario and watch what the CPU is doing during the waits:

Three consequences follow from "one thread, switch only at await":

Coroutines, await, and Tasks

Precise vocabulary prevents most asyncio confusion:

TermWhat it is
Coroutine functionA function defined with async def. Calling it does not run its body.
Coroutine objectWhat calling a coroutine function returns: a paused computation. It runs only when awaited or wrapped in a Task.
AwaitableAnything usable after await: coroutines, Tasks, Futures, objects with __await__.
TaskA coroutine scheduled to run on the loop independently. Created by asyncio.create_task or TaskGroup.create_task. It's a subclass of Future.
FutureA low-level placeholder for a result that will be set later (by a callback, a thread, or the loop).

The crucial difference: await coro() runs the coroutine now, inside the current task, and waits for it. Concurrency only happens when you create Tasks, because each Task is a separate entry the loop can switch between. Under the hood, a coroutine is a generator-like object: await works much like yield from, passing suspension requests up to the Task that drives it.

created ready running suspended done: result done: exception done: cancelled scheduled await (not ready) I/O or timer ready
A Task alternates between running and suspended (each await on something not ready), until it finishes with a result, an exception, or cancellation.

gather vs TaskGroup: structured concurrency

asyncio.gather(*aws) runs awaitables concurrently and returns their results in argument order. Its error handling is the problem: by default, the first exception propagates to the awaiting code, but the other tasks keep running, unsupervised, in the background. With return_exceptions=True, exceptions are returned as values in the result list, which is handy for "try everything, report failures" but easy to forget to check.

asyncio.TaskGroup (3.11+) implements structured concurrency: tasks are created inside an async with block and cannot outlive it. The block waits for all of them. If any task fails, the group cancels all the others, waits for them to finish cancelling, and raises an ExceptionGroup containing every failure. Lifetimes are visible in the code's indentation, like variables in a scope.

The except* syntax (3.11+) matches exceptions inside an ExceptionGroup: each except* clause handles the matching subgroup, and anything unmatched is re-raised. Several clauses can run for one group.

Timeouts

Every network operation needs a timeout; a peer that never answers otherwise hangs your task forever. async with asyncio.timeout(seconds): (3.11+) cancels the enclosed code if it runs too long and converts that cancellation into TimeoutError at the async with boundary. Because it scopes a block, one deadline can cover several awaits (connect, send, read). Related tools:

Cancellation semantics

Cancellation is asyncio's mechanism for stopping work: timeouts, TaskGroup failures, and shutdown all use it. task.cancel() doesn't kill anything immediately. It schedules a CancelledError to be raised inside the task at its current (or next) await. The task can then run cleanup in finally blocks and async with exits, and must let the exception propagate.

Producer/consumer with asyncio.Queue

asyncio.Queue mirrors queue.Queue but its put/get are coroutines that suspend instead of blocking a thread. It's not thread-safe; it's for tasks on one loop. The standard worker-pool shape:

  1. Create the queue (optionally with maxsize for back-pressure) and N worker tasks.
  2. Each worker loops: item = await queue.get(), process, and queue.task_done() in a finally.
  3. The producer puts items; then await queue.join() waits until every item is done.
  4. Stop workers: cancel them, put one sentinel per worker, or (3.13+) call queue.shutdown(), which makes pending and future get() calls raise asyncio.QueueShutDown once the queue is empty.

Limiting concurrency and rate

Creating 10,000 tasks is cheap; opening 10,000 simultaneous connections to one server is rude and usually fails. Two different limits are often needed:

Async iterators, generators, and context managers

Some protocols need to await in the middle: fetching the next page of results, or closing a connection. Python has async versions of the iteration and context-manager protocols:

SyncAsyncDunder methods
for x in itasync for x in ait__aiter__, async __anext__ (raise StopAsyncIteration)
generator (yield in def)async generator (yield in async def)generated for you; aclose() for cleanup
with cmasync with acmasync __aenter__, async __aexit__
@contextmanager@asynccontextmanagerfrom contextlib
comprehension[x async for x in ait]inside an async def (or top-level await)
next(), iter()await anext(), aiter()built-ins since 3.10

Mixing in blocking code

Not every library is async. A synchronous database driver, requests, urllib, or a CPU-heavy function will block the loop. The bridges:

Inspecting running async programs

Inside a program, the same information is available as an API. asyncio.capture_call_graph(task) returns a FutureCallGraph (the task, its coroutine call stack, and who awaits it); asyncio.print_call_graph(task) and format_call_graph render it. Omit the argument to describe the current task.

Preview: TaskGroup.cancel() in 3.15

In 3.14 you can get the same behavior portably with asyncio.wait(tasks, return_when=FIRST_COMPLETED) followed by cancelling the pending set, or by the "raise a private exception" pattern:

Common bugs and how to spot them

BugSymptomFix
Forgetting awaitRuntimeWarning: coroutine '...' was never awaited; a coroutine object where you expected a valueAdd await; enable debug mode; mypy flags unused coroutines
Blocking the loop (time.sleep, requests, CPU loops)Everything stalls; timeouts fire late; heartbeats skipawait asyncio.sleep, async libraries, to_thread, executors
Fire-and-forget tasksLost exceptions; tasks vanish (GC)TaskGroup, or keep references
Swallowing CancelledErrorTimeouts don't stop work; TaskGroup exit hangsAlways re-raise it
Awaiting in a loop when you meant concurrencyTotal time = sum of latenciesCreate tasks first, then await them (or use a TaskGroup)
Check-then-act across an awaitDuplicate work (fetching the same URL twice)Mark state before awaiting, or hold an asyncio.Lock
asyncio.run() inside a running loopRuntimeError: asyncio.run() cannot be called from a running event loopJust await (as in these browser cells and Jupyter)

Practice

Write async def fetch_all(urls, fetch) that calls the async function fetch(url) for every URL concurrently using an asyncio.TaskGroup, and returns a dict mapping each URL to its result. Duplicated URLs must be fetched only once.

Hint

Create one task per unique URL inside the async with block, keep them in a dict, and read task.result() after the block. dict.fromkeys(urls) de-duplicates while keeping order.

Show solution
import asyncio

async def fetch_all(urls, fetch):
    async with asyncio.TaskGroup() as tg:
        tasks = {url: tg.create_task(fetch(url)) for url in dict.fromkeys(urls)}
    return {url: task.result() for url, task in tasks.items()}

Write async def bounded_map(func, items, limit) that awaits func(item) for every item with at most limit calls in flight at once, and returns results in input order. Raise ValueError if limit < 1.

Hint

Wrap each call in a small coroutine that does async with sem: return await func(item), then gather them (gather preserves order) or use a TaskGroup and read results afterwards.

Show solution
import asyncio

async def bounded_map(func, items, limit):
    if limit < 1:
        raise ValueError("limit must be >= 1")
    sem = asyncio.Semaphore(limit)

    async def one(item):
        async with sem:
            return await func(item)

    async with asyncio.TaskGroup() as tg:
        tasks = [tg.create_task(one(item)) for item in items]
    return [t.result() for t in tasks]

Write async def with_fallback(aw, seconds, default) that returns the result of awaiting aw, or default if it takes longer than seconds. Use asyncio.timeout. Other exceptions must propagate. Then write async def first_ok(coros, seconds) that tries each awaitable in order with that timeout and returns the first non-timeout result, or raises TimeoutError if all time out.

Hint

try: async with asyncio.timeout(seconds): return await aw then except TimeoutError: return default. For first_ok, use a unique sentinel object as the default so a legitimate None result isn't mistaken for a timeout.

Show solution
import asyncio

async def with_fallback(aw, seconds, default):
    try:
        async with asyncio.timeout(seconds):
            return await aw
    except TimeoutError:
        return default

_TIMED_OUT = object()

async def first_ok(coros, seconds):
    for aw in coros:
        result = await with_fallback(aw, seconds, _TIMED_OUT)
        if result is not _TIMED_OUT:
            return result
    raise TimeoutError("every attempt timed out")

Write an async generator countdown(n, log) that yields n, n-1, ..., 1, awaiting asyncio.sleep(0) before each value, and appends "closed" to the list log when it finishes or is closed early. Then write async def take(agen, k) that returns a list of the first k items and closes the generator deterministically (use contextlib.aclosing).

Hint

Put the loop in try and the log.append("closed") in finally. In take: async with aclosing(agen) as g: async for x in g: ..., breaking when you have k.

Show solution
import asyncio
from contextlib import aclosing

async def countdown(n, log):
    try:
        for i in range(n, 0, -1):
            await asyncio.sleep(0)
            yield i
    finally:
        log.append("closed")

async def take(agen, k):
    out = []
    async with aclosing(agen) as g:
        async for item in g:
            out.append(item)
            if len(out) == k:
                break
    return out

Write async def process_all(items, handler, workers) that pushes every item into an asyncio.Queue, starts workers worker tasks that each await handler(item), and returns the list of results in the same order as items. Use queue.join() to know when all items are processed, and make sure no worker task is still running when the function returns. If a handler raises, the exception must propagate (an ExceptionGroup is fine).

Hint

Queue (index, item) pairs and store results in a list pre-filled with None. Run workers in a TaskGroup; after await queue.join(), put one None sentinel per worker. If a worker crashes, the TaskGroup cancels the body (including the join()), so the error still propagates.

Show solution
import asyncio

async def process_all(items, handler, workers):
    queue = asyncio.Queue()
    results = [None] * len(items)
    for pair in enumerate(items):
        queue.put_nowait(pair)

    async def worker():
        while True:
            job = await queue.get()
            try:
                if job is None:
                    return
                index, item = job
                results[index] = await handler(item)
            finally:
                queue.task_done()

    async with asyncio.TaskGroup() as tg:
        for _ in range(workers):
            tg.create_task(worker())
        await queue.join()
        for _ in range(workers):
            queue.put_nowait(None)
    return results

Mini-project: an async rate-limited fetcher simulator

Mini-projectfetchsim: a polite, resilient async client

Real APIs throttle you (HTTP 429), fail intermittently (503), and occasionally hang. Build a simulator of such an API and a client that fetches 200 resources from it as fast as the rules allow, without ever exceeding the limits. Everything runs locally with asyncio.sleep standing in for the network, so it's fast and deterministic with a seeded RNG.

pixi init fetchsim
cd fetchsim
pixi add python=3.14 pytest
pixi run python fetchsim.py --requests 200 --concurrency 10 --rate 50
pixi run python -m pytest -q

Specification

  • FakeAPI(seed, latency=(0.01, 0.05), error_rate=0.05, hang_rate=0.01, max_rate=50) with async def get(path): sleeps a random latency, sometimes raises ServerError(503), sometimes hangs for 10 s, and returns 429 if called more than max_rate times in any sliding 1-second window.
  • Client with a Semaphore (concurrency), a token-bucket RateLimiter (rate), per-request asyncio.timeout, and retries with exponential backoff and jitter on 503, 429, and timeouts.
  • fetch_many(paths) runs inside a TaskGroup, returns results in input order, and reports stats: successes, retries, timeouts, 429s, p50/p95 latency, and total time.
  • Ctrl+C (or an overall asyncio.timeout) cancels cleanly and prints partial stats.
  • While it runs, inspect it from another terminal with pixi run python -m asyncio pstree <PID>.

Acceptance criteria

  • All 200 requests eventually succeed with the default settings
  • The client never receives a 429 when its rate is set at or below the API's limit
  • Peak concurrency never exceeds the semaphore size (asserted in a test)
  • Hung requests are cut off by the timeout and retried
  • A test cancels fetch_many midway and checks that no tasks remain
  • Tests use an injected sleep/clock or tiny latencies and finish in under two seconds

Stretch goals

  • Honor a Retry-After value carried by the 429 response.
  • Adaptive concurrency: shrink the semaphore on 429s and grow it on successes (AIMD).
  • Plot the latency distribution with matplotlib.
  • Then build the real thing: Project 5: Async Web Crawler.

Quiz

Recap

Going further