Backend / Async & concurrency / 20_async_python_deep_dive.md

Async Python, end to end

Updated 6 interview angles 7 min read source
On this page11
  1. The whole stack
  2. The map
  3. Async generators: cleanup runs later than you think
  4. Context variables and tasks
  5. Eager tasks
  6. Seeing inside a running loop
  7. uvloop
  8. Where it breaks in production
  9. The trade not taken
  10. Related
  11. Interview angle

Async Python, end to end

Article.

The Step 5 notes take asyncio apart one mechanism at a time. This one puts it back together, for the question that tests whether the pieces connect:

“Explain what happens from async def to the socket, and what goes wrong in production.” The first half is a map through the other notes; the second half covers what they do not: async generator cleanup, context variables across tasks, eager tasks, looking inside a running loop, and uvloop. Measurements are from CPython 3.14.5 on Windows and 3.14.8 in a Linux container where noted.

The whole stack

text
async def handler()          defines a coroutine function
handler()                    a coroutine object: nothing runs yet
asyncio.create_task(...)     wraps it in a Task, scheduled on the loop
loop: run ready callbacks    each Task step runs until its next await
await reader.read()          not ready: the Task parks on a Future
selector / IOCP              the loop asks the OS which sockets are ready
socket readable              the Future completes, the Task is ready again

Every rule of asyncio follows from that picture. Code between two awaits runs alone, so it is atomic, and if it is slow, everything stops. A coroutine nobody awaits or wraps in a task never runs. A task nobody holds a reference to can be garbage-collected mid-flight. Cancellation is an exception thrown in at the await where the task is parked.

The map

Topic Note
coroutines and await 05
the event loop 06
blocking I/O 18
asyncio or threads 07, 08
sync code from async 14
gather vs TaskGroup 12
cancellation and timeouts 13
races across await 10
queues and backpressure 11
anyio and Trio 16
FastAPI FastAPI and the event loop

Async generators: cleanup runs later than you think

python
async def rows():
    try:
        async for row in cursor:            # e.g. a database cursor
            yield row
    finally:
        await cursor.close()                # releases the connection

Leaving an async for early does not run that finally at once:

text
plain break:     ['after the loop', 'generator cleanup', 'later']
with aclosing:   ['generator cleanup', 'after the loop']

A synchronous generator is closed when its last reference goes. An async generator cannot be: closing it means running awaits, and that needs the loop. So asyncio schedules the cleanup as a task through the loop’s async-generator hooks, and it runs some time later, or at loop shutdown, or, if the loop is already gone, never. Meanwhile the database connection stays checked out. contextlib.aclosing() closes it at the end of the block:

python
from contextlib import aclosing

async with aclosing(rows()) as it:
    async for row in it:
        if done(row):
            break                           # cleanup runs here

Context variables and tasks

contextvars is how request ids, tracing spans and auth carry through async code without passing them explicitly. A task gets a copy of the context at create_task:

text
parent sets request_id = 'req-42', then awaits a child task
child saw 'req-42', set its own value; parent still sees 'req-42'

Values flow down into tasks, never back up. That is the right default (a child cannot corrupt its parent’s request id) and the source of a classic bug: a value set inside a task, or inside a callback run with loop.run_in_executor, is invisible to the caller. asyncio.to_thread copies the context into the thread; run_in_executor does not (run_in_executor and asyncio.to_thread).

Eager tasks

A Task normally waits for the next loop iteration before its first step. If the coroutine can finish without ever suspending (a cache hit, a validation error), that round trip is pure overhead. With the eager task factory (3.12), create_task runs the coroutine immediately until its first real suspension:

python
loop.set_task_factory(asyncio.eager_task_factory)
# 3.14: per task, asyncio.create_task(coro, eager_start=True)
text
200,000 tasks that finish without awaiting:
  default task factory:  1.335 s
  eager_task_factory:    0.587 s

The cost is ordering: an eager task’s first step runs inside create_task, before the caller continues, so code that assumed “the task has not started yet” changes behaviour, and an early exception surfaces at a different point.

Seeing inside a running loop

Debug mode

asyncio.run(main(), debug=True) or PYTHONASYNCIODEBUG=1 logs every callback slower than loop.slow_callback_duration (100 ms by default):

text
Executing <Task finished name='Task-1200028'
          coro=<blocking() ...> took 0.2xx seconds

That line names the coroutine that blocked the loop. Debug mode also tracks where unawaited coroutines were created. It is slow; use it in development and staging.

The mistakes asyncio reports

text
RuntimeWarning: coroutine 'blocking' was never awaited
Task exception was never retrieved
future: <Task finished ... coro=<boom() done ...> exception=RuntimeError('lost')>

The first is a call without await: nothing ran. The second is a task whose exception nobody read; it is only logged when the task is garbage-collected, so it can appear far from the cause, or never. Treat both as errors in tests (-W error::RuntimeWarning).

python -m asyncio ps and pstree (3.14)

Python 3.14 can read the task tree of a running process from outside, without changing its code (it uses the remote debugging support of PEP 768):

text
$ python -m asyncio pstree 12
└── (T) Task-1
    └──  main /w/tree.py:15
        └──  TaskGroup._aexit ...
                ├── (T) request-1
                │   └──  handle_request /w/tree.py:9
                │       ├── (T) db-1
                │       │   └──  fetch /w/tree.py:5
                │       │       └──  sleep tasks.py:712
                │       └── (T) cache-1
                └── (T) request-2 ...

ps prints the same as a table: each task, its coroutine stack and the task awaiting it. For a service that hangs with no error, this answers “what is every task waiting on” in one command; in a container it needs the SYS_PTRACE capability. Name tasks (create_task(..., name=...)) so the tree is readable.

uvloop

uvloop replaces the event loop with one built on libuv:

text
50 clients x 2,000 echo round trips over TCP, Linux container:
  asyncio  34,775 round trips/s
  uvloop   42,035 round trips/s

About 1.2× here, less than the two to four times often quoted: the loop’s own overhead is a small share when each request also does Python work, and asyncio’s loop has itself become faster. It helps most for very many small I/O operations with little work per message. It does not run on Windows.

Where it breaks in production

The failures in the other notes, ranked by how often they cause incidents:

  1. Blocking the loop: a sync driver, time.sleep, CPU work, or a large json.loads inside async def; every request waits (18, FastAPI and the event loop).
  2. Unbounded fan-out: gather over 10,000 coroutines opens 10,000 connections; use a semaphore inside the task (12).
  3. Lost tasks: create_task without keeping a reference, or a failure in a background task nobody awaits (12).
  4. Swallowed cancellation: except Exception or except CancelledError: pass that keeps a task running after shutdown or a timeout (13).
  5. Races across await: read, await, write back (10).
  6. Async generators never closed: connections held until shutdown (above).
  7. Primitives bound to another loop: a lock or queue created at import and used in a second asyncio.run, typically in tests (10).

The trade not taken

asyncio buys thousands of concurrent waits on one thread at the price of “coloured” functions: every caller of an async function must be async, and one blocking call stalls everything. Threads keep ordinary code and cost a stack and a context switch each; with the GIL they are fine for I/O, and on the free-threaded build they also run CPU work in parallel (Free-threaded Python).

Most backends end up with both: an async web layer, thread pools for sync libraries, processes for CPU. The skill is knowing which part of a request runs where.

Interview angle 6

  • “Walk me from async def to the socket.” - calling it makes a coroutine object; a Task schedules it; the loop runs each step until an await on something not ready; the Task parks on a Future; the loop asks the OS (epoll, kqueue, IOCP) which sockets are ready and resumes the Tasks waiting on them.
    • Follow-up: “So why is code between awaits atomic, and why is that dangerous?” - only one Task runs at a time and it is never pre-empted; if that code is slow, every other request waits.
  • “Why didn’t my async generator’s finally run?” - an early break leaves it to the loop’s finaliser, which runs later or at shutdown: measured, the cleanup ran after the code following the loop. Use contextlib.aclosing.
  • “A value I set in a context variable inside a task isn’t visible outside. Why?” - each task runs in a copy of the context taken at creation. Values flow into tasks, not back out.
    • Follow-up: “How do you close it when you control the generator but not the caller?” - you cannot force it from inside; document that callers use aclosing, or return a context manager instead of a generator.
  • “What are eager tasks?” - create_task runs the coroutine immediately until its first suspension: 200,000 cache-hit tasks took 0.587 s instead of 1.335 s. Ordering changes, so it is opt-in.
  • “The service hangs and logs nothing. What do you do?” - on 3.14, python -m asyncio pstree <pid> shows every task and what it awaits; before that, py-spy dump for threads and debug mode for slow callbacks.
  • “Should we use uvloop?” - measure: here it was 1.2× on a TCP echo benchmark. Worth it for many small I/O operations; not on Windows.