Why waiting hurts
A download spends most of its time waiting for the network to answer. Three downloads of 1, 2 and 3 seconds, done one after another, take 6 seconds in total, and the program sits idle nearly all that time.
Tasks and the loop
async def makes a coroutine: a function that can pause in the middle and carry on later. await marks the spot where it may pause. Here asyncio.sleep(secs) stands in for a slow download.
gather() wraps each call in a task, A, B and C, and lines them up in the ready queue. A task is a coroutine that the event loop has been asked to run.
The event loop is a loop running on one thread. It takes the task at the front of the ready queue and runs it. Only one task runs at any moment. A prints start A.
A reaches await. It does not block the thread: it parks until its wait is over and hands control back to the loop, which is free at once to run the next task.
The loop does the same with B and C: run until await, then park. Almost at once all three downloads are waiting at the same time, and the one thread is free.
Waiting together
After 1 second A's wait is over. The loop puts A back in the ready queue, then resumes it on the line after await. A prints done A and finishes.
B resumes at 2 seconds and C at 3. All three finish in about 3 seconds, the time of the slowest download, not the 6 seconds of the sum. The waits overlapped on one thread.
Beyond Python
JavaScript runs on an event loop too. fetch() returns a Promise, an object for a result that arrives later, and await pauses an async function until it settles. Older code passes a callback: a function called when the result is ready.
A task hands the loop back only at await. A CPU-heavy calculation never awaits, so every other task waits behind it, even one whose data has arrived. Run such work in a worker process with run_in_executor().
Code and output
;; step 1/10 · Waiting wastes time
import time
def fetch(name, secs):
print("start", name)
time.sleep(secs) # blocks: nothing else can run
print("done", name)
fetch("A", 1); fetch("B", 2); fetch("C", 3)
# about 1 + 2 + 3 = 6 seconds
The full program also needs import asyncio at the top and asyncio.run(main()) at the bottom; asyncio.run() starts the event loop and runs main() on it. Strictly, the ready queue holds callbacks, not tasks. A parked task is resumed by a callback that runs its next step, which the loop schedules with call_soon() once the thing it awaits is done. asyncio.sleep() stands in for a network wait. A real download would use asyncio streams or a library such as aiohttp, and the loop treats it the same way: the task parks until its data arrives.