gap·map

Python asyncio

[ middle depth ]

How the asyncio event loop runs your coroutines

asyncio runs on one thread with one event loop. The loop holds a queue of ready tasks and runs them one at a time. A coroutine keeps that thread until it hits an await on something not yet ready (a socket, a timer, another coroutine); at that point it suspends and hands control back to the loop, which picks the next ready task. This is cooperative scheduling: a task yields the CPU only by awaiting, and nothing preempts it.

Calling a coroutine function does not run it. fetch() builds a coroutine object and runs zero lines of the body. The body executes only once the loop drives it, which happens through asyncio.run(main()) at the top, or await, or asyncio.create_task.

async def fetch(url): ...

fetch("/a")                 # builds a coroutine, runs nothing (and warns: never awaited)
await fetch("/a")           # runs it, waits for the result
asyncio.create_task(fetch("/a"))  # schedules it on the loop, returns immediately

Why two awaits run one after another

await waits for completion before the next line. So two awaited calls are serial even when they never depend on each other:

a = await get(url1)   # 1s
b = await get(url2)   # another 1s, starts only after the first finished

To overlap them you have to hand both to the loop before awaiting either. asyncio.gather does that: it schedules every argument as a task, lets their I/O run at the same time, and returns results in argument order.

a, b = await asyncio.gather(get(url1), get(url2))   # ~1s

create_task is the lower-level version: it schedules now, and you await the task later. The rule to carry into interviews: overlap requires scheduling, not just async syntax. Marking a function async buys you nothing until something runs it concurrently.

The blocking-call trap

Because everything shares one thread, any synchronous call that takes real time freezes the whole loop. time.sleep(2), requests.get(...), a sync database driver, a tight CPU loop: while that runs, no other task moves, timeouts drift, heartbeats stall.

Wrong "It's inside an async function, so it won't block." Being inside async def changes nothing; only await yields.

The fix is to keep blocking work off the loop thread. Swap in an async equivalent (await asyncio.sleep, an async HTTP client, an async driver) where one exists. When it does not, push the call to a worker thread with await asyncio.to_thread(do_work) or loop.run_in_executor. The coroutine still awaits a result; the blocking part just no longer runs on the loop.

[ senior depth ]

gather vs TaskGroup: what structured concurrency buys you

Both run coroutines concurrently; they differ on failure. asyncio.gather(*aws) with defaults propagates the first exception to whoever awaits the gather, but the other awaitables are not cancelled. They keep running with no one holding their result, which is the classic orphaned-task bug: a request handler returns an error while two background calls it started are still hitting the database. return_exceptions=True changes this to collect every outcome (values and exceptions) into the result list instead of raising.

asyncio.TaskGroup (3.11+) is structured concurrency. On the first task that fails with anything other than CancelledError, it cancels the remaining siblings, waits for them to unwind, then raises the failures together as an ExceptionGroup.

async with asyncio.TaskGroup() as tg:
    tg.create_task(fetch(a))
    tg.create_task(fetch(b))
# leaving the block awaits all tasks; if any failed, siblings were
# cancelled and errors arrive as an ExceptionGroup you catch with except*

The scope owns the tasks: nothing you spawned outlives the block. Prefer it over gather for anything where a partial failure should not leave work running.

The fire-and-forget garbage-collection trap

The loop keeps only a weak reference to a task. If you write asyncio.create_task(coro()) and keep no reference, the garbage collector can destroy the task mid-flight, and it silently stops, sometimes with a "Task was destroyed but it is pending" warning, often with nothing.

Wrong "The loop is running it, so it's alive." The loop scheduling a task does not root it.

Hold a strong reference until the task finishes:

_tasks = set()

def spawn(coro):
    t = asyncio.create_task(coro)
    _tasks.add(t)
    t.add_done_callback(_tasks.discard)   # drop it once done, no leak

A TaskGroup solves this too, because the group keeps its tasks referenced for the life of the block. Interviewers raise this because the naive version passes every local test (short tasks finish before GC runs) and fails under load.

When async beats threads, and when it is pointless

asyncio wins on I/O concurrency at scale: thousands of mostly-idle sockets, where a suspended coroutine costs a few KB against an OS thread's ~1MB stack, and cooperative switching sidesteps lock contention. Chat servers, proxies, scrapers, API gateways.

It does nothing for CPU-bound work. One thread, cooperative scheduling, and the GIL (see python-gil-concurrency) mean a pure computation loop just blocks the loop; you want a process pool. And async is dead weight when a program has no concurrency to exploit: a script making one call after another is simpler as plain synchronous code.

Two grading moves separate senior answers. First, reaching for to_thread or run_in_executor only when a call genuinely cannot be made async, and knowing each offloaded call consumes a real thread from a bounded pool. Second, treating cancellation as cooperative: CancelledError is raised inside the task at its next await, so cleanup belongs in try/finally, and a bare except: that swallows it turns a cancel into a hang.

beta

The interviewer part is in the works.

The diagnostic, personal maps, and AI mock interviews are being finished right now. The notes stay free either way. Leave an email and you'll get the first-cohort invite, plus a month of Pro when it opens.

builds on

more Python

was this useful?