Coroutines
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
asyncio.run(main()) creates an event loop, wraps the main() coroutine in a task and runs the loop until main finishes, then closes it. The loop is one thread: it runs one task at a time.
Inside async with asyncio.TaskGroup() as tg, create_task() wraps each coroutine in a Task and schedules it: tea, then coffee, join the ready queue. Neither has started yet; main is still the one running.
At the end of the async with block the group waits for all of its tasks. main awaits there: it parks, and the loop is free to run something else. Line 9 runs only when both tasks are done.
The loop takes tea from the front of the queue and runs brew("tea", 2) up to await asyncio.sleep(2). The sleep sets a 2-second timer and suspends tea, which hands control back to the loop.
coffee runs the same way and parks on a 1-second timer. All three tasks are parked now, so the loop has nothing to run: it sleeps until the earliest timer is due.
Waiting together
At t = 1 s coffee's timer fires. coffee goes back in the queue, and the loop resumes it on the line after its await. return gives back 'coffee ready', and the finished Task keeps it as its result.
At t = 2 s tea resumes and returns 'tea ready'. tea started first but finished last: both timers ran at the same time, so the slower one sets the pace.
Both tasks are done, so main resumes after the block. t1.result() and t2.result() read the stored values and print shows tea ready coffee ready. Total: about 2 seconds, the slower task, not 1 + 2 = 3.
One trap
Without tasks, main awaits brew("tea", 2) and then brew("coffee", 1). Awaiting a coroutine runs it to the end inside main before the next line starts, so the waits add up: 3 seconds. Tasks let them overlap.
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.