1import asyncio2async def brew(drink, secs):3 await asyncio.sleep(secs)4 return f"{drink} ready"5async def main():6 async with asyncio.TaskGroup() as tg: # no tasks: one await, then the next7 t1 = tg.create_task(brew("tea", 2)) r1 = await brew("tea", 2)8 t2 = tg.create_task(brew("coffee", 1)) r2 = await brew("coffee", 1)9 print(t1.result(), t2.result()) print(r1, r2)10asyncio.run(main())t = 0.0 st1t2mainteacoffee
Code card: brew.py; highlighted lines are the ones explainedEvent loop: runs one task at a time on one thread, switching only at awaitReady queue and parking bays: tasks waiting to run, and tasks waiting on somethingResult cells and terminal: what the tasks return and what main printsTask tokens hop along each step's arrow: from their create_task() line into the ready queue, onto the event loop to run, out to a bay when they reach await (the bay shows what they wait for), back through the queue when the wait is over, and into a result cell with their return value. The clock on the back block runs only while every task is parked: tea (2 s) and coffee (1 s) wait at the same time, so the program takes 2 s. Awaited one after another, the same waits take 3 s. While every task is parked, the loop asks the operating system to wake it when a socket is ready or the next timer is due (Python's selectors module), so waiting costs no CPU.

Coroutines

  1. async def makes brew a coroutine function. Calling brew("tea", 2) runs none of its body: it only builds a coroutine object, a paused computation. Something must drive it: asyncio.run(), an await, or a Task.

Tasks and the loop

Waiting together

One trap

Code and output

;; step 1/10 · A coroutine waits to run
>>> import asyncio
>>> async def brew(drink, secs):
...     await asyncio.sleep(secs)
...     return f"{drink} ready"
...
>>> brew("tea", 2)
<coroutine object brew at 0x...>
>>> asyncio.run(brew("tea", 2))   # 2 s later
'tea ready'

asyncio.TaskGroup needs Python 3.11 or newer. On older versions, asyncio.gather(brew("tea", 2), brew("coffee", 1)) overlaps the two coroutines in the same way and returns both results as a list. Strictly, the ready queue holds callbacks, not tasks: when a timer fires, the loop schedules a callback with call_soon() that resumes the parked task, and such callbacks run in the order they were scheduled. About 2 seconds: a little more in practice, because starting tasks and switching between them takes a small amount of time.