async / await & asyncio
Run many I/O tasks concurrently with coroutines and asyncio.gather.
Concurrency on one thread
A web service that fetches 50 URLs spends almost all its time waiting on the network, not computing. Threads can overlap that waiting, but you pay for context switches, GIL contention, and locks around shared state. asyncio overlaps it on a single thread: while one task waits, the loop runs another. Because a task runs uninterrupted until its next await, you rarely reach for a lock. This is the default tool for I/O-bound fan-out (many network calls or database queries) in modern Python.
Coroutines are values, not running code
async def defines a coroutine function. Calling it does not run the body; it returns a coroutine object that is suspended at the top, waiting to be driven:
import asyncio
async def fetch_one(n):
await asyncio.sleep(0.1) # hands control back to the loop while "waiting"
return n * 10
coro = fetch_one(1) # nothing has run yet; coro is a coroutine object
await is the only place a coroutine gives up control. Between awaits it runs straight through like ordinary code.
A single thread. The loop holds three ready coroutines.
Overlapping the waiting with gather
asyncio.gather schedules many coroutines at once and waits for all of them, returning results in argument order (not finish order):
async def main():
return await asyncio.gather(fetch_one(1), fetch_one(2), fetch_one(3))
asyncio.run(main()) # [10, 20, 30] after ~0.1s total, not 0.3s
asyncio.run(coro) is the one synchronous door into async code: it starts a fresh event loop, runs the coroutine to completion, and closes the loop.
Why this sandbox uses a helper
asyncio.run refuses to start when a loop is already running and raises RuntimeError. This sandbox runs your code inside a loop, so it hands you run_coroutines(coros) instead. It drives each coroutine with coro.send(None) and reads the return value off the resulting StopIteration. That works because the sandbox fetch_one awaits nothing, so a single send finishes it. You still build real coroutines with fetch_one(n); you just pass them to run_coroutines in place of asyncio.run(asyncio.gather(*coros)).
Pitfall: a coroutine you never drive
Calling fetch_one(5) and treating the result like a number does nothing useful. The body never runs, and Python warns RuntimeWarning: coroutine 'fetch_one' was never awaited. A coroutine only executes when something awaits it, gathers it, or runs it on a loop. The reverse trap is just as common: never put a blocking call (time.sleep, a synchronous DB driver, a heavy compute loop) inside a coroutine, because it freezes the whole thread and no other task can make progress.
Interview nuance: asyncio is cooperative, not preemptive. The event loop can only switch tasks at an await; it cannot interrupt running code. So gather speeds up work that spends its time awaiting real I/O, but a CPU-bound coroutine (or a stray time.sleep) starves every other task on the loop. That is the core reason asyncio scales I/O-bound fan-out yet does nothing for CPU-bound work, where you reach for processes instead.
# Normally: asyncio.run(asyncio.gather(fetch_one(1), fetch_one(2), fetch_one(3)))
# This sandbox is already inside an event loop, so we drive the coroutines directly:
async def fetch_one(n):
return n * 10
def run_coroutines(coros):
out = []
for coro in coros:
try:
coro.send(None)
except StopIteration as done:
out.append(done.value)
return out
print(run_coroutines(fetch_one(n) for n in [1, 2, 3])) # [10, 20, 30]Apply
Your turn
The task this lesson builds to.
Warm-up (one file): implement fetch_all(numbers) to build a fetch_one(n) coroutine for each
number and run them all with the provided run_coroutines helper, returning the results in order.
(In a normal program you'd write asyncio.run(asyncio.gather(*coros)); this sandbox is already
inside an event loop, so run_coroutines stands in for it.)
fetch_all([1, 2, 3]) is [10, 20, 30].
3 hints and 3 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Implement fetch_all(numbers) in aio/gather.py: build a fetch_one(n) coroutine for every
number and run them with the read-only run_coroutines helper (imported from aio.loop),
returning the results in order. Some tests are hidden.
3 hints and 2 automated checks are waiting in the workspace.