PYTHON / ADVANCED PYTHON
asyncio: coroutines and the event loop
Run async code with asyncio.run, understand exactly what await suspends, and overlap waiting work with gather instead of sequential awaits.
What you will learn
- Calling an async def function only builds a coroutine object; await or a task runs it
- await hands control back to the event loop, the only point where another task can run
- asyncio.gather turns several coroutines into tasks so their waiting periods overlap
- Blocking calls like time.sleep stall the entire loop; use asyncio.sleep or to_thread
Understanding asyncio: coroutines and the event loop
A function defined with `async def` is a coroutine function, and calling it does not execute anything: it returns a coroutine object holding the not-yet-started frame. Something has to drive that frame, and that something is the event loop, a single-threaded scheduler holding a queue of callbacks that are ready to run plus a set of timers and sockets it is watching. `asyncio.run(main())` creates a fresh loop, wraps the coroutine in a Task, runs the loop until that task finishes, then closes the loop.
`await` is the suspension point. When you await something that is not finished yet, your coroutine gives control back to the loop together with the information about what should wake it up; the loop is then free to run any other ready task until the timer fires or the socket becomes readable. Because there is exactly one thread, the code between two awaits runs to completion with no interruption at all — this is cooperative scheduling, not preemption. That is also why one synchronous blocking call, like `time.sleep` or a non-async database driver, freezes every other task in the program.
Awaiting a coroutine directly gives you no concurrency: it just runs inline, like a normal function call that happens to be able to suspend. To get overlap you need more than one task in flight, which is what `asyncio.gather` and `asyncio.create_task` produce. `gather` schedules each argument as its own task, waits for all of them, and returns results in argument order, so total time is roughly the slowest wait rather than the sum of all waits.
import asyncio
import time
async def fetch(name, delay):
print(f"start {name}")
await asyncio.sleep(delay)
print(f"done {name}")
return name.upper()
async def main():
coro = fetch("a", 0.2)
print("created:", type(coro).__name__) # the body has not run yet
print("awaited:", await coro) # now it runs
t0 = time.perf_counter()
results = await asyncio.gather(fetch("b", 0.3), fetch("c", 0.1))
print("gathered:", results)
print(f"elapsed: {time.perf_counter() - t0:.1f}s")
asyncio.run(main())await is the only place a coroutine yields to the event loop, so concurrency exists only where you have several tasks suspended at awaits at the same time.
Worked examples
A blocking call defeats the loop
Shows that three coroutines only overlap if they suspend with await, not if they call time.sleep.
import asyncio
import time
async def blocking(n):
time.sleep(0.1) # never yields to the loop
return n
async def cooperative(n):
await asyncio.sleep(0.1) # registers a timer, then yields
return n
async def timed(label, coros):
t0 = time.perf_counter()
await asyncio.gather(*coros)
print(f"{label}: {time.perf_counter() - t0:.1f}s")
async def main():
await timed("time.sleep ", [blocking(i) for i in range(3)])
await timed("asyncio.sleep", [cooperative(i) for i in range(3)])
asyncio.run(main())Example explained
Line 1gather(*coros) schedules all three coroutines as tasks before awaiting any of them.
Line 2time.sleep(0.1) holds the only thread, so the loop cannot start task two until task one returns: 0.3s.
Line 3await asyncio.sleep(0.1) suspends, so all three timers run at once and the total is 0.1s.
Line 4Being inside an `async def` does not make a call non-blocking; only awaiting a suspending object does.
Where the switches actually happen
Uses asyncio.sleep(0) as a pure yield to expose the interleaving between a task and the main coroutine.
import asyncio
async def counter(name, n):
for i in range(n):
print(name, i)
await asyncio.sleep(0) # yield, resume on the next loop pass
async def main():
task = asyncio.create_task(counter("A", 3))
await counter("B", 3) # runs inline, inside main's own task
await task
asyncio.run(main())Example explained
Line 1create_task schedules counter("A", 3) at once, but it cannot run until main suspends.
Line 2await counter("B", 3) creates no task, so B executes as part of main and prints first.
Line 3asyncio.sleep(0) puts the current task at the back of the ready queue, producing strict alternation.
Line 4await task at the end is what guarantees A finished; dropping it could end the program mid-count.
Important notes
asyncio.run creates a loop and closes it, so it cannot be called from inside a coroutine or in an environment that already runs a loop (Jupyter raises RuntimeError there — just `await main()` instead).
Concurrency here is not parallelism: one coroutine executes at a time on one thread, so CPU-bound code gets no faster from asyncio; send it to asyncio.to_thread or a process pool.
Common mistakes
Calling `fetch("a")` and forgetting the `await`: the body never runs, the expression is just an unused coroutine object, and Python prints 'RuntimeWarning: coroutine ... was never awaited' at shutdown.
Using `time.sleep` or a synchronous HTTP client inside a coroutine: the single thread is blocked, every other task is frozen for that whole time, and gather ends up exactly as slow as sequential code.
Writing `for url in urls: await fetch(url)` and expecting concurrency: each await finishes before the next coroutine is even created, so total time is the sum of all delays.
Try it yourself
Change, predict, then run
Write two coroutines that await asyncio.sleep(0.5) and asyncio.sleep(0.2) and print when they finish, then run them twice: once with two sequential awaits, once with asyncio.gather. Print the elapsed time from time.perf_counter for each version and confirm you get roughly 0.7s and 0.5s.
Open the Python workspaceCheck your understanding
A coroutine does `for u in urls: await fetch(u)` where fetch awaits network I/O. Why is this no faster than plain synchronous code?
- await suspends the calling coroutine until that single fetch completes, so the next fetch is not even created until the previous one finishes
- The event loop only runs one callback per iteration of a for loop, so iteration limits the throughput
- await only overlaps work when it appears inside an `async for` loop rather than a plain for loop
- Coroutines can never overlap without threads; gather works only because it starts a thread per coroutine
Show answer
Overlap needs several tasks suspended at awaits simultaneously; a sequential await keeps exactly one in flight, so the loop has nothing else to run while it waits. Option 4 is tempting but wrong: gather does not use threads at all — it wraps each coroutine in a Task on the same single-threaded loop, and the loop switches between them at their await points.