Asynchronous Python
Lesson 2 of 5
2Asynchronous Python: Coroutines & the Event Loop
The big pictureHow your program stops wasting time waiting: you separate waiting from computing, write coroutines with async/await, run dozens of operations together with gather and as_completed, control them as Tasks, and measure the difference in seconds yourself.
What the picture shows: A runs a little then waits, so the loop moves to B, then C, then back to A when what it waited for arrives. One piece of work at a time, and no waiting goes to waste.
Map of this part
The concepts in order, click any one to jump to it:
- Two kinds of slow: waiting or computing?
- Concurrency is not parallelism
- A coroutine: a function that knows how to pause itself
- The event loop: the manager that hands out turns
- What async and await each actually do
- Three ways to run several coroutines (and the difference matters)
- Coroutine versus Task
- Measuring time: what exactly are you measuring
- Type annotations on coroutines
Two ways into the same idea
Picture a waiter in a café. The slow waiter takes your order and stands by the coffee machine until it is ready, then takes the next order. The smart waiter starts the machine and goes to take another order, coming back when it beeps. Same person, same machines, but waiting no longer wastes time.
async def defines a coroutine, and await yields to the event loop while waiting on the network or disk. asyncio.gather runs several coroutines together, create_task schedules them as tasks, and as_completed returns them in finishing order. This is concurrency, not parallelism: it helps with waiting, not heavy computation.
Required Fundamentals
Functions and return values (the Functions lesson)
A coroutine is a function first: it takes arguments and returns a value. The only new thing is that it can pause in the middle and resume later.
Exceptions (the Exceptions lesson)
Cancellation in asyncio reaches you as a CancelledError exception, and whoever knows try/except/finally already knows how to clean up after it.
Lists and unpacking (the Data structures lesson)
gather(*coros) relies on unpacking a list of coroutines, and the result is a list ordered by the arguments.
the REST APIs lesson: APIs
The clearest place the gain shows up: ten HTTP requests one after another take the sum of their times; run together they take the time of the slowest one.
Core Concepts
This whole part fits in one sentence: most program slowness is not computation, it is waiting. And when the slowness is waiting, you do not need a faster processor or extra threads; you only need the program to make use of those waiting periods instead of standing still in them.
1Two kinds of slow: waiting or computing?
Before anything else, classify your problem, because the cure is completely different:
| Kind | Where the time goes | Examples | The cure |
|---|---|---|---|
| I/O-bound | In waiting: the CPU is completely idle | A network request, reading a file, a database query, waiting for an API reply | asyncio or threads |
| CPU-bound | In computing: the CPU is 100% busy | Image processing, encryption, compression, a long numeric loop | multiprocessing or a faster language |
2Concurrency is not parallelism
Confusing the two is the source of most wrong expectations:
Concurrency
Dealing with several jobs in the same period by switching between them. One barista starts the espresso machine and, while it runs, steams the milk. One worker, but no wasted time.
Parallelism
Executing several jobs at literally the same instant. Two baristas working side by side. It needs multiple CPU cores.
asyncio gives you concurrency without parallelism: one thread, one coroutine executing at any instant, but nobody standing idle. And that is exactly why asyncio has no “race on variables” in the sense you fear with threads: a switch between tasks happens only at an await, and you are the one who writes where those are.
3A coroutine: a function that knows how to pause itself
An ordinary function, once started, does not stop until it finishes. A coroutine can say “I am about to wait for something, take the turn, do not waste it on me”, and later resume from that same line with all of its variables intact.
| Term | What it is exactly |
|---|---|
| Coroutine function | A function defined with async def. It is a definition; nothing has run yet. |
| Coroutine object | What calling it returns: c = random_delay(5). Not a single line has executed, just a packed promise. |
| Task | A coroutine handed to the event loop so it starts running immediately in the background. |
| Awaitable | Anything you may legally put await in front of: a coroutine, a task, or a Future. |
async function without await does not execute its body at all, and raises no error: it hands you an object which you then drop, so Python prints RuntimeWarning: coroutine ... was never awaited. The program finishes “successfully” having forgotten half its work.
4The event loop: the manager that hands out turns
The event loop is the heart of asyncio: a single loop that keeps the list of ready tasks, gives each one the turn until it pauses at an await, then takes the turn back and gives it to another. And when nobody is ready, it sleeps until a network reply arrives or a timer expires.
asyncio.run(main()) is what opens the loop, runs main inside it, and closes it afterwards. It is the one recommended entry point, and it is called once, from ordinary synchronous code, never from inside a coroutine.
5What async and await each actually do
async def
Turns the function into a coroutine function. That is all it does: it makes nothing parallel and nothing faster by itself. It is purely a declaration that “this function may pause in the middle”.
await x
Says: “run x and give me its result, and if it needs to wait, hand the turn back to the loop until it is ready.” It is forbidden outside an async function.
asyncio.sleep(n)
A wait that yields the turn: the loop runs somebody else during those n seconds. Its opposite is time.sleep(n), which freezes the whole thread and every task with it.
asyncio.run(coro)
Opens the loop, waits for coro to finish, closes the loop, and returns the value. The bridge between your synchronous world and asyncio's.
6Three ways to run several coroutines (and the difference matters)
| The way | What it does | Total time | Result order |
|---|---|---|---|
await a(); await b() | One after the other; no concurrency at all | The sum of the times | The order you wrote |
await asyncio.gather(a(), b()) | Runs them all together and waits for them all | The longest one | Always argument order |
asyncio.as_completed([a(), b()]) | Hands you each one the moment it finishes | The longest one | Completion order |
sort()” puzzle
When every task is merely a wait of a certain length, the first to finish is the one with the shortest delay. Which means as_completed hands you the delays already in ascending order by nature, not because you sorted them, but because time sorted them. gather, by contrast, preserves argument order, so its list arrives unsorted. We prove both with numbers in step 5.
7Coroutine versus Task
The difference is the moment it starts:
| Coroutine object | Task | |
|---|---|---|
| How it is created | c = coro() | t = asyncio.create_task(coro()) |
| When it starts | Only at await | Immediately, running in the background |
| Can it be cancelled | No | Yes: t.cancel() |
| Can you inspect it | No | Yes: t.done(), t.result() |
| Its type | coroutine | asyncio.Task |
This is why a function that returns a task is written with a plain def and not async def: create_task waits for nothing, it registers the task and returns it at once. Making the function async would force the caller to await it just to obtain the task, which is a layer of waiting that buys nothing.
8Measuring time: what exactly are you measuring
Use time.perf_counter(), not time.time(): the former is a monotonic clock meant for measuring differences and is unaffected by the system clock being adjusted. And the correct measurement wraps the entire asyncio.run(...), because what you want to know is the real time the user waited.
9Type annotations on coroutines
Your projects are corrected on Python 3.8, and that imposes specific rules:
| The function | The correct 3.8 annotation | Why |
|---|---|---|
async def random_delay(...) | -> float | Annotate the final value, not Coroutine, because you will write await to obtain it. |
async def gather_delays(...) | -> List[float] | From typing: the built-in list[float] does not work as an annotation before 3.9. |
def as_task(...) | -> asyncio.Task | An ordinary function returning a task object, so the type is the task itself. |
list[float] and str | None both raise TypeError on 3.8. Import List and Optional from typing instead. Likewise, as_completed is not used with async for on 3.8; you write a plain for and then await each item, as in step 5.
How This Actually Works, Step by Step
Four pictures capture the idea: where the time goes, the states a coroutine passes through, what happens inside the loop at an await, and when asyncio is the right answer at all.
Diagram 36, Where the time goes: in case A the two waits are consecutive so they add up; in case B they overlap so the longest one rules. Notice the work itself did not change, only the arrangement of the waiting.
Diagram 37, The coroutine lifecycle. The first state is the cause of the “was never awaited” warning: an object that stayed at C and never moved on.
Diagram 38, What happens inside gather. Switching between tasks happens only at an await; which is why one of them doing heavy computation with no await would stop everything.
Diagram 39, The decision tree. The first question is not “should I use async?” but “where is the time going?”, and answering it picks the tool.
- Write the functions that wait with
async def, and make every wait inside them anawait. - Collect whatever can run together into
gatheroras_completed, not into aforloop withawaitinside it. - Use
create_taskwhen you want work to start in the background while you carry on. - Run everything from a single entry point:
asyncio.run(main()). - Measure with
perf_counterbefore believing you gained anything.
Worked Example: a Coffee Shop Preparing Orders Together
We stay in the coffee shop from the previous parts, but this time the problem is time. Every piece of code and every output below was actually executed; the numbers are real, not estimated.
Step 1, The problem: two orders one after the other
import asyncio, time
async def brew(name, secs):
await asyncio.sleep(secs) # stands in for a real wait: a machine, a network, a disk
return f"{name} ready after {secs}s"
async def sequential():
t0 = time.perf_counter()
a = await brew("espresso", 1) # we stand here for a full second
b = await brew("latte", 2) # then two more
return round(time.perf_counter() - t0, 2), [a, b]
print("sequential:", asyncio.run(sequential()))
# sequential: (3.0, ['espresso ready after 1s', 'latte ready after 2s'])Three seconds. Notice the code is fully async and still gained nothing: writing async does not give you concurrency; concurrency comes from running things together.
Step 2, The same work with gather
async def concurrent():
t0 = time.perf_counter()
res = await asyncio.gather(brew("espresso", 1), brew("latte", 2))
return round(time.perf_counter() - t0, 2), res
print("concurrent:", asyncio.run(concurrent()))
# concurrent: (2.0, ['espresso ready after 1s', 'latte ready after 2s'])Two seconds instead of three: the time became the longer of the two waits, not their sum. And with ten drinks whose longest is two seconds, it would still be two seconds.
Step 3, A coroutine does nothing until you await it
c = brew("x", 0)
print("type:", type(c).__name__) # type: coroutine
c.close() # closed by hand so it prints no warningCalling the function produced an object only. Not one line of its body ran.
Step 4, gather keeps argument order, not completion order
async def finish_order():
order = []
async def job(n, d):
await asyncio.sleep(d)
order.append(n) # record who actually finished, and when
return n
got = await asyncio.gather(job("slow", 0.3), job("fast", 0.1))
return got, order
print(asyncio.run(finish_order()))
# (['slow', 'fast'], ['fast', 'slow'])The result is ['slow', 'fast'] in the order you wrote, while the real completion order was ['fast', 'slow']. This is not a problem, it is a feature: you can match results to their inputs by index.
Step 5, as_completed and the secret of the ascending order
import random
random.seed(7)
async def random_delay(max_delay=10):
d = random.uniform(0, max_delay) # 0 <= d <= max_delay
await asyncio.sleep(d)
return d
async def gather_delays(n, max_delay):
out = []
for done in asyncio.as_completed([random_delay(max_delay) for _ in range(n)]):
out.append(await done) # each one arrives the moment it finishes
return out
res = asyncio.run(gather_delays(5, 3))
print([round(x, 3) for x in res]) # [0.217, 0.453, 0.971, 1.608, 1.953]
print(res == sorted(res)) # True with no sort()And for a direct comparison, the same function with gather:
async def wait_n_gather(n, max_delay):
return await asyncio.gather(*(random_delay(max_delay) for _ in range(n)))
res2 = asyncio.run(wait_n_gather(5, 3))
print([round(x, 3) for x in res2]) # [1.097, 0.174, 1.522, 0.112, 1.301]
print(res2 == sorted(res2)) # FalseStep 6, create_task starts immediately
async def demo():
t0 = time.perf_counter()
coro = random_delay(1) # not scheduled yet
task = asyncio.create_task(random_delay(1)) # scheduled now, already running
await asyncio.sleep(0.4)
print(" after 0.4s -> task done?", task.done(), "| type:", type(task).__name__)
a = await task
b = await coro
return round(time.perf_counter() - t0, 2)
print("total:", asyncio.run(demo()), "s")
# after 0.4s -> task done? True | type: Task
# total: 0.95 sThe task progressed and finished while we were waiting for something else, and the coroutine sat still until its turn came. Note that create_task requires a running loop, so do not call it outside asyncio.run.
Step 7, Cancellation
async def cancel_demo():
t = asyncio.create_task(asyncio.sleep(5))
await asyncio.sleep(0.1)
t.cancel()
try:
await t
except asyncio.CancelledError:
return "CancelledError raised, loop kept running"
print(asyncio.run(cancel_demo()))
# CancelledError raised, loop kept runningCancellation reaches you as an exception inside the task, which is what makes finally a reliable place to close a connection or a file even if the task is cancelled halfway.
Step 8, Forgetting await
async def oops():
random_delay(1) # no await: the body will never run
return "body of random_delay never ran"
# output: body of random_delay never ran | warning: RuntimeWarningNo error and no crash, just an easily ignored warning and half your logic missing. If you see coroutine ... was never awaited, go looking for a call with a missing await.
Step 9, Measuring the average time per operation
def time_runs(n, max_delay): # a plain def, not async
t0 = time.perf_counter()
asyncio.run(gather_delays(n, max_delay)) # the measurement wraps the whole run
return (time.perf_counter() - t0) / nWhy does the result fall as n grows? Because the numerator (the total time) approaches the longest single wait and barely changes, while the denominator n grows. That division is itself a neat measure of how much concurrency bought you.
Step 10, One blocking call freezes everything
async def good(): await asyncio.sleep(0.5) # yields the turn async def bad(): time.sleep(0.5) # holds the whole thread # await asyncio.gather(good(), good()) -> 0.5 s # await asyncio.gather(bad(), bad()) -> 1.0 s
The same structure and the same gather, and the difference is double the time. The rule: inside a coroutine, every wait must be an await on an asynchronous version; and anything with no asynchronous version is moved off the loop with loop.run_in_executor.
Myth vs. Reality
| The myth | The reality |
|---|---|
| “async makes code faster.” | It does not make any single operation faster by one millisecond. It only stops waiting time from being wasted. Async code without gather takes exactly as long as synchronous code, as you saw in step 1. |
| “async runs things in parallel.” | One thread, one coroutine executing at any instant. Real parallelism needs multiprocessing. What you get is overlapping waits. |
| “await means wait here and stop the program.” | It pauses this coroutine only and gives the turn to another. The program carries on, and that is the whole point. |
“As long as I wrote async def, the function does not block.” | Blocking comes from its contents: time.sleep, requests.get, or a heavy numeric loop freezes the loop inside any function, whatever its definition. |
| “gather returns results in completion order.” | Always in argument order. Whoever wants completion order uses as_completed. |
| “Coroutines remove the need for threads entirely.” | They beat threads at waiting, and fail at heavy computation and at synchronous libraries with no async version. Real systems mix the two. |
Where Is This Used in Practice?
Fetching several APIs together
A page that needs data from three services: sequentially it costs the sum of their times, with gather the time of the slowest. This is the single most common practical use.
Modern web servers
FastAPI, Starlette and aiohttp are built on this loop: one thread serves thousands of open connections because most of them are waiting.
Databases (the Python and databases lesson)
Asynchronous drivers let several queries run without the application stopping at each one.
Calling AI models
Every call is a relatively long network wait; running ten of them with gather shrinks the total to the slowest one.
Crawling websites
Hundreds of pages on one thread with little memory, with a Semaphore capping how many requests run at once so you do not flood the server.
Timeouts
asyncio.wait_for(coro, timeout=5) cancels the operation automatically if it takes too long, which is far cleaner than hand-rolled timers.
Try it yourself
Three tasks each wait one second with await asyncio.sleep(1) and run under gather. Roughly how long in total?
The waits overlap on one thread: the loop starts all three and waits for them together, so everything finishes after about a second.
Quick Review Flashcards
async def definition; the object is what calling it returns, and it runs no line until awaited or made a task.Check Your Understanding
1. You converted ten functions to async def and put await before each call inside a for loop, and the time did not change. Why?
Because await inside the loop means “wait for this one to finish, then start the next”, which is sequence, not concurrency. Concurrency begins when you hand all ten together to gather or as_completed, or when you turn them into tasks with create_task before awaiting them.
2. A function that returns an asyncio.Task: why is it written with a plain def rather than async def?
Because create_task waits for nothing: it registers the coroutine on the loop and returns the task at once. Making it async would force the caller to write await just to obtain the task, adding a layer of waiting that buys nothing.
3. (Spot the bug) A coroutine reads a file with open().read() then sends it with await session.post(...), and the server freezes under load. What is the cause?
The read is synchronous and blocks the single thread. While it runs, the loop cannot serve any other request. The fix is either an asynchronous file library or running the read off the loop with run_in_executor. The presence of an await on a later line does not redeem the blocking line before it.
4. Why does as_completed produce a list of delays sorted ascending with no sort(), and when does the trick fail?
Because each task is merely a wait of a given length, so the shortest finishes first and arrives first. The trick fails immediately if the tasks differ in nature (a network request versus a disk read), because completion order then reflects nothing about the values returned.
5. (Decision) You want to fetch 1000 pages. Do you gather all thousand at once?
No. A thousand simultaneous connections can exhaust ports, anger the target server, and get your address blocked. Use an asyncio.Semaphore to cap a sensible number at a time (20, say) and run all thousand through it, or split them into batches. Useful concurrency has a ceiling, and exceeding it backfires.
Resources for this part
Sorted by kind: start with the official documentation for the precise reference, or with an article or video if you want the explanation first.
Resources for this lesson
Grouped by type. Start with the official docs for precision, or an article or video for the explanation.
Official docs2
Done with this lesson?
Sign in to save your progress.