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.
- Explain the event loop: cooperative scheduling,
awaitas a suspension point, and the ready queue - Distinguish coroutine functions, coroutine objects, Tasks, and Futures, and create tasks safely
- Choose between
asyncio.gatherandTaskGroup, and handleExceptionGroups withexcept* - Apply timeouts with
asyncio.timeoutand write cancellation-safe code - Build producer/consumer pipelines with
asyncio.Queueand limit concurrency with semaphores - Use async iterators, async generators, and async context managers; run blocking code with
to_thread - Inspect running programs with 3.14's
python -m asyncio ps/pstreeand call-graph APIs
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":
- Races are rarer and visible. Code between two
awaits runs without interruption, soself.count += 1needs no lock. But anycheck → await → actsequence can still interleave with other tasks (you'll see this in Project 5). - Blocking is catastrophic. A task that calls
time.sleep(1)or a blocking HTTP library freezes every task for that second, because the loop can't run anything else until it returns. - No parallelism. asyncio is for I/O-bound concurrency. CPU-heavy work stalls the loop just like blocking I/O; offload it (see Mixing in blocking code).
Coroutines, await, and Tasks
Precise vocabulary prevents most asyncio confusion:
| Term | What it is |
|---|---|
| Coroutine function | A function defined with async def. Calling it does not run its body. |
| Coroutine object | What calling a coroutine function returns: a paused computation. It runs only when awaited or wrapped in a Task. |
| Awaitable | Anything usable after await: coroutines, Tasks, Futures, objects with __await__. |
| Task | A coroutine scheduled to run on the loop independently. Created by asyncio.create_task or TaskGroup.create_task. It's a subclass of Future. |
| Future | A 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.
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:
asyncio.timeout_at(loop.time() + n): an absolute deadline, ideal for propagating one budget through nested calls.cm.reschedule(new_deadline)on the context manager to extend or shorten it;timeout(None)means "no deadline yet".asyncio.wait_for(aw, timeout): the older single-awaitable form, now implemented withtimeout.
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.
CancelledErrorinherits fromBaseException, notException, so a genericexcept Exception:doesn't swallow it. Never catch it without re-raising (unless you are the code that requested the cancellation, and even then prefer the structured tools).- Cleanup code may
await(closing a connection), but keep it short: a second cancel can interrupt it. asyncio.shield(aw)protects an inner awaitable from cancellation of the outer task: the outer await is cancelled, but the inner operation keeps running. Use it rarely (for example, finishing a commit).- Internally, tasks count cancellation requests (
task.cancelling(),task.uncancel()); this is howtimeout()andTaskGroupdistinguish their own cancellations from yours.
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:
- Create the queue (optionally with
maxsizefor back-pressure) and N worker tasks. - Each worker loops:
item = await queue.get(), process, andqueue.task_done()in afinally. - The producer puts items; then
await queue.join()waits until every item is done. - Stop workers: cancel them, put one sentinel per worker, or (3.13+) call
queue.shutdown(), which makes pending and futureget()calls raiseasyncio.QueueShutDownonce 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:
- Concurrency limit (how many at once):
asyncio.Semaphore(n). Each task doesasync with sem:around the request; at most n are inside. - Rate limit (how many per second): a token bucket or a minimum interval between starts. A semaphore alone doesn't bound rate: fast responses let tasks cycle through it quickly.
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:
| Sync | Async | Dunder methods |
|---|---|---|
for x in it | async 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 cm | async with acm | async __aenter__, async __aexit__ |
@contextmanager | @asynccontextmanager | from 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:
await asyncio.to_thread(func, *args)(3.9+): run a blocking function in the loop's default thread pool; perfect for blocking I/O (the GIL is released while it waits). This is how Project 5's real fetcher usesurllib.await loop.run_in_executor(executor, func, *args): the same with an executor of your choice; use aProcessPoolExecutor(orInterpreterPoolExecutor) for CPU-bound work.asyncio.run_coroutine_threadsafe(coro, loop): the reverse direction, submitting a coroutine to a loop running in another thread (returns aconcurrent.futures.Future).
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
| Bug | Symptom | Fix |
|---|---|---|
Forgetting await | RuntimeWarning: coroutine '...' was never awaited; a coroutine object where you expected a value | Add await; enable debug mode; mypy flags unused coroutines |
Blocking the loop (time.sleep, requests, CPU loops) | Everything stalls; timeouts fire late; heartbeats skip | await asyncio.sleep, async libraries, to_thread, executors |
| Fire-and-forget tasks | Lost exceptions; tasks vanish (GC) | TaskGroup, or keep references |
Swallowing CancelledError | Timeouts don't stop work; TaskGroup exit hangs | Always re-raise it |
| Awaiting in a loop when you meant concurrency | Total time = sum of latencies | Create tasks first, then await them (or use a TaskGroup) |
Check-then-act across an await | Duplicate work (fetching the same URL twice) | Mark state before awaiting, or hold an asyncio.Lock |
asyncio.run() inside a running loop | RuntimeError: asyncio.run() cannot be called from a running event loop | Just 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
Quiz
Recap
- asyncio runs many tasks on one thread, switching only at
await; never block the loop. - Coroutine functions create coroutine objects; Tasks schedule them concurrently. Keep references, or better, use a
TaskGroup. TaskGroupgives structured concurrency andExceptionGroups; handle them withexcept*.- Use
asyncio.timeoutfor deadlines; cancellation is an exception raised at an await, so clean up infinallyand re-raise. asyncio.Queue(withtask_done/join, sentinels, or 3.13'sshutdown()) builds worker pools;Semaphorebounds concurrency; rate needs its own limiter.async for, async generators, andasync withhandle protocols that need to await; close generators withaclosing.to_threadand executors bridge blocking code; 3.14 addspython -m asyncio ps/pstreeandcapture_call_graph.
Going further
asynciodocumentation, especially Coroutines and Tasks and Developing with asyncio- Call graph introspection (3.14)
- Notes on structured concurrency (Nathaniel J. Smith)
- PEP 654: Exception Groups and except*
- Next: M39 Performance & Profiling