Python Module 15 – Async programming with asyncio
Async Python: the event loop mental model
Learn what async def, await and the asyncio event loop do, why one blocking call stalls every task, and which problems async code solves well.
What you will learn
- Explain what a coroutine is and when its body runs
- Trace how the event loop switches between tasks at each await
- Show why one blocking call or busy loop stalls every task on the loop
- Recognise a forgotten await from the warning Python prints
- Identify the problems async code solves well and the ones it does not
Before you start
What you’ll learn · Before you start
What you will learn
- Explain what a coroutine is and when its body runs
- Trace how the event loop switches between tasks at each await
- Show why one blocking call or busy loop stalls every task on the loop
- Recognise a forgotten await from the warning Python prints
- Identify the problems async code solves well and the ones it does not
Before you start
On this page
A thousand replies of 0.2 seconds each take 200 seconds when a program waits for them one after another. asyncio
waits for all of them at once, in one thread, and the run measured below finishes in about 0.2 seconds. It does that
by letting tasks take turns: a task runs until it reaches an await on something that is not ready yet, such as a
reply from a server, and while it waits the event loop: The part of asyncio that runs tasks one at a time in one thread. It runs a task until it awaits something that is not ready, runs other ready tasks meanwhile, and resumes the first once its timer fires or its data arrives. runs another task that is ready.
Nothing else runs between two awaits of a task, so the switching is
cooperative scheduling: Taking turns where each task decides when to give up control. An asyncio task runs until it awaits something that is not ready, and nothing switches it out before that, so a task that never awaits holds up all the others.: a task that computes for half a second, or calls a blocking
function such as time.sleep(), holds up every other task for that half second. Below you will see when a
coroutine’s body runs, step through the turns tasks take, watch a busy loop stall a timer, catch the forgotten
await, and sort the jobs asyncio suits from the ones it does not.
A coroutine runs only when something drives it
greet() below is a coroutine function: A function defined with async def. Calling it runs none of its body: it returns a coroutine object, which runs only when something drives it, such as await, a task or asyncio.run()., a function defined with async def.
Calling it is not running it. The call returns a coroutine: The object a call to an async def function returns. Driven by await, a task or asyncio.run(), it runs its body up to each await on something not ready, pauses there with its local variables kept and resumes later from the same spot. object, and the body starts
only when an event loop runs it:
"""Calling an async def function runs none of its body: it makes a coroutine object."""
import asyncio
import inspect
async def greet(name):
print(f" greet({name!r}) starts")
await asyncio.sleep(0)
print(f" greet({name!r}) carries on after its await")
return f"hello, {name}"
coro = greet("Asha")
print("greet('Asha') returned a", type(coro).__name__)
print("its state:", inspect.getcoroutinestate(coro))
result = asyncio.run(coro) # start an event loop, run the coroutine to its end, close the loop
print("asyncio.run() returned:", repr(result))
print("its state now:", inspect.getcoroutinestate(coro)) Output
greet('Asha') returned a coroutine
its state: CORO_CREATED
greet('Asha') starts
greet('Asha') carries on after its await
asyncio.run() returned: 'hello, Asha'
its state now: CORO_CLOSED
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 coroutine_object.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
greet("Asha") printed nothing: it returned a coroutine object in the state CORO_CREATED. asyncio.run() then did
three things. It created an event loop, ran the coroutine until it returned, and closed the loop again, handing back
the coroutine’s return value. The await asyncio.sleep(0) inside paused the coroutine for one turn of the loop and
let it continue. Afterwards the coroutine is CORO_CLOSED, so it cannot run a second time. Step through the same run
and watch which part runs the body:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- The call. coro = greet("Asha") creates a coroutine object and runs none of the body, so nothing from greet() is printed. Its state is CORO_CREATED.
- Handed to asyncio.run(). asyncio.run(coro) creates an event loop and gives it the coroutine, to run until it returns.
- The body starts. The loop starts the body, which is now CORO_RUNNING. It prints "greet('Asha') starts" and reaches await asyncio.sleep(0).
- One turn of the loop. await asyncio.sleep(0) pauses the coroutine for one turn and hands control back to the loop. Paused, it is CORO_SUSPENDED, with its local variables kept.
- Resumed. The loop has nothing else to run, so it resumes the body right after the await. It prints "carries on after its await" and reaches return.
- The return value. The body returns "hello, Asha" to the loop. The coroutine is finished, CORO_CLOSED, and it can never run again.
- Back in your code. asyncio.run() closes the loop and returns the coroutine's value, which the program prints on the line asyncio.run() returned 'hello, Asha'.
Call asyncio.run() once, at the top of a program, with the coroutine that does everything else; await itself is
only allowed inside an async def. The browser runner of this site and the asyncio REPL, started with
python -m asyncio, also accept await at the top level, which is handy for trying things out.
Checkpoint
Check: Calling makes a coroutine; driving runs it
await pauses a coroutine and hands control to its driver
A coroutine can pause part-way through its body and carry on later from the same spot, with all its local variables,
like the generators of an earlier lesson, which pause at yield. An event loop is code that resumes coroutines by
calling their send() method. This program plays the event loop itself, without asyncio:
"""An event loop is code that calls send() on coroutines. Here we play the loop by hand."""
import types
@types.coroutine
def pause(reason):
# A bare yield is how a coroutine tells whoever drives it: "I am waiting, run something else".
yield reason
async def checkout():
print("checkout: reserve the items")
await pause("waiting for the payment service")
print("checkout: payment done, send the receipt")
await pause("waiting for the mail server")
print("checkout: receipt sent")
return "order 1042 complete"
coro = checkout()
step = 1
while True:
try:
why = coro.send(None) # run the coroutine until it pauses
except StopIteration as finished: # the coroutine returned
print(f"driver: finished after {step} sends, result {finished.value!r}")
break
print(f"driver: send #{step} came back with {why!r}")
step += 1 Output
checkout: reserve the items driver: send #1 came back with 'waiting for the payment service' checkout: payment done, send the receipt driver: send #2 came back with 'waiting for the mail server' checkout: receipt sent driver: finished after 3 sends, result 'order 1042 complete'
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 hand_driven.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Each send(None) ran checkout() until the next pause and came back with the value the coroutine handed out. The
third send() let checkout() run to its return, which ends a coroutine the way it ends a generator: with
StopIteration carrying the result. Step through the three sends and watch where checkout() stops each time:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Created. coro = checkout() creates the coroutine. None of its body has run, and the driver has sent nothing yet.
- Send #1. coro.send(None) starts the body. It prints "reserve the items" and reaches await pause("waiting for the payment service").
- The first pause. The bare yield inside pause() hands the reason out, and checkout() stops on its await with its place kept. send() returns the reason, and the driver prints it.
- Send #2. The second send(None) resumes checkout() right after its first await. It prints "payment done, send the receipt" and reaches the second pause.
- The second pause. This time checkout() hands out "waiting for the mail server". A real event loop would now run other coroutines until the mail server answers.
- Send #3. The third send(None) resumes the body after its second await. It prints "receipt sent" and reaches return "order 1042 complete".
- StopIteration. A coroutine that returns raises StopIteration in its driver, with the result in its value attribute. The driver catches it and prints that it finished after 3 sends.
A real loop does the same with thousands of coroutines. When one hands back “I am waiting for this socket” or “wake me in 0.2 seconds”, the loop notes what it waits for, asks the operating system to report when the socket has data, and moves on to the next coroutine. You never write this driver yourself, but it is the whole mental model in a few lines.
Key idea
A coroutine pauses only at an await that reaches something not ready; everything between two pauses runs without
interruption, and whatever drives the coroutine decides when it carries on.
Checkpoint
Check: await pauses and hands control back
The event loop lets tasks take turns at each await
An event loop repeats one cycle, drawn below: it takes a task that is ready, runs it until its next await, and
parks it while it waits for something that is not ready yet.
The cycle an event loop repeats
Text description of the diagram
The diagram is a cycle of three boxes and one end point.
- The ready queue holds the tasks that can run now. The loop takes the next one.
- The loop runs that task until its next await. No other task runs during this step.
- If the task awaits asyncio.sleep(0), it goes straight to the back of the ready queue.
- If it awaits something that is not ready yet, such as a timer or a reply on a socket, it is parked with the waiting tasks.
- When the timer fires or the data arrives, the waiting task goes back into the ready queue.
- A task that returns or raises leaves the cycle with its result or its exception.
To run several coroutines at once, wrap each one in a asyncio task: A coroutine wrapped by asyncio.create_task() and scheduled on the running event loop. By default it starts the next time the code that is running pauses, runs alongside other tasks, and keeps the coroutine's result or exception for whoever awaits it. More in “Tasks, gather and running coroutines” with
asyncio.create_task(). A task is scheduled at once, and the loop runs it as soon as the code that is running now
pauses:
"""Two tasks on one event loop: each runs until its next await, then the loop runs the other."""
import asyncio
async def quick_check(name):
# Awaiting a coroutine that never waits is an ordinary call: no other task runs during it.
return f"{name} is valid"
async def worker(name, steps):
for i in range(1, steps + 1):
print(f"{name}: step {i}, {await quick_check(name)}")
await asyncio.sleep(0) # a real pause: the loop may run another task now
print(f"{name}: done")
async def main():
upload = asyncio.create_task(worker("upload", 3))
email = asyncio.create_task(worker("email", 2))
print("main: two tasks created, neither has run yet")
await upload
await email
print("main: both finished")
asyncio.run(main()) Output
main: two tasks created, neither has run yet upload: step 1, upload is valid email: step 1, email is valid upload: step 2, upload is valid email: step 2, email is valid upload: step 3, upload is valid email: done upload: done main: both finished
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 taking_turns.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Creating the two tasks ran neither of them: main() kept going until its own await upload. Then the loop ran
upload to its first await asyncio.sleep(0), then email to its first, then upload again, and so on, so the
lines interleave. await asyncio.sleep(0) gives up the turn without waiting for any time to pass, which the asyncio
documentation describes as the optimised way to let other tasks run. Step through the run on the cycle of the
diagram; the sub-labels say which tasks sit in each box, in queue order:
The drawing shows the whole scene; its 14 steps are listed below.
Every step as text
- main() runs. asyncio.run() runs main() as the first task. main() creates the upload and email tasks, which join the ready queue without running, and prints its first line.
- main() waits for upload. main() reaches await upload. upload has not finished, so main() is parked until it has, and the thread is free for the loop's next task.
- upload, step 1. The loop takes upload from the front of the queue. It prints step 1; await quick_check() returns at once, so upload runs on to await asyncio.sleep(0).
- The back of the queue. await asyncio.sleep(0) gives up the turn without waiting for any time to pass. upload goes to the back of the ready queue, behind email.
- email, step 1. email is at the front now. It prints its step 1 and runs to its own await asyncio.sleep(0).
- email gives up its turn. email goes to the back of the queue, behind upload. This is why the printed lines alternate between the two tasks.
- upload, step 2. upload takes its second turn. It prints step 2, reaches await asyncio.sleep(0) and goes to the back of the queue again.
- email, step 2. email takes its second turn the same way. It prints step 2 and goes behind upload.
- upload, step 3. upload prints step 3, the last of its three, and gives up its turn once more.
- email is done. email had two steps. Its loop ends, it prints "email: done" and returns, so it leaves the cycle for good.
- upload is done. upload's loop ends too. It prints "upload: done" and returns.
- main() is ready again. upload's end is what main() waited for, so the loop moves main() back into the ready queue.
- main() finishes. main() resumes after await upload. await email returns at once, because email has already finished, so main() prints "main: both finished" and returns.
- The loop is empty. Every task has finished and nothing waits, so asyncio.run() closes the loop. One thread ran all three tasks, one turn at a time.
Notice the other await in each line, await quick_check(name). It never caused a switch, because quick_check()
returns without waiting for anything. Awaiting a coroutine is an ordinary call that happens to be allowed to pause.
Only when something down the chain of calls waits for a result that is not ready does control go back to the loop.
Checkpoint
Check: Tasks take turns at each await
A thousand waits overlap in one thread
Waiting is where asyncio earns its keep. While a task waits, it costs no CPU time and holds no thread, so one loop can keep a great many waits in progress:
"""A thousand waits at once, in one thread, and the memory each waiting task holds."""
import asyncio
import threading
import time
import tracemalloc
async def wait_for_reply(request_id):
await asyncio.sleep(0.2) # stands in for a server that takes 0.2 s to answer
return request_id
async def main():
tracemalloc.start()
before, _ = tracemalloc.get_traced_memory()
start = time.perf_counter()
tasks = [asyncio.create_task(wait_for_reply(i)) for i in range(1000)]
await asyncio.sleep(0) # every task starts and pauses at its sleep
waiting, _ = tracemalloc.get_traced_memory()
replies = [await task for task in tasks]
took = time.perf_counter() - start
tracemalloc.stop()
print(f"replies: {len(replies)}, threads in this program: {threading.active_count()}")
print(f"memory held by each waiting task: about {(waiting - before) / 1000 / 1024:.1f} KiB")
print(f"one after another this would take {1000 * 0.2:.0f} s; together it took about {took:.1f} s")
asyncio.run(main()) Output
replies: 1000, threads in this program: 1 memory held by each waiting task: about 1.3 KiB one after another this would take 200 s; together it took about 0.2 s
This output changes from run to run: the time and the memory are measured on each run; setting up a thousand tasks takes longer on a slower computer and in the browser, whose 32-bit Python also uses smaller objects
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 thousand_waits.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
A thousand replies of 0.2 seconds each, awaited one after another, would take 200 seconds. As tasks they waited at
the same time and the whole job took about 0.2 seconds on CPython, in a program with a single thread. tracemalloc,
which records the memory Python allocates, put the cost of one waiting task at about 1.3 KiB: the task object, its
paused coroutine and the timer that will wake it. A thousand of them fit in about 1.3 MiB, which is why one asyncio
process can hold tens of thousands of open connections. The work done between the waits still runs one task at a
time, so the total CPU time does not shrink: only the waiting overlaps.
Checkpoint
Check: Waits overlap; computing does not
One blocking call stalls every task
Cooperative scheduling has a price. The loop can switch tasks only when the running task awaits, so a task that runs
for a long time without awaiting keeps every other task waiting. Here a ticker task asks to run every 0.1 seconds,
while main() waits for 0.45 seconds in two ways:
"""A ticker task should print every 0.1 s. What does it manage while main() waits for 0.45 s?"""
import asyncio
import time
async def ticker(ticks):
while True:
await asyncio.sleep(0.1)
ticks.append(time.perf_counter())
def compute_for(seconds):
# CPU-bound work: a loop that never awaits, like parsing a large file or resizing an image.
deadline = time.perf_counter() + seconds
count = 0
while time.perf_counter() < deadline:
count += 1
return count
async def awaited_wait():
await asyncio.sleep(0.45)
async def busy_wait():
compute_for(0.45)
async def measure(label, wait):
ticks = []
tick_task = asyncio.create_task(ticker(ticks))
await asyncio.sleep(0) # let the ticker start
await wait()
tick_task.cancel()
print(f"{label:30} ticks while it ran: {len(ticks)}")
async def main():
await measure("await asyncio.sleep(0.45)", awaited_wait)
await measure("a 0.45 s loop with no await", busy_wait)
asyncio.run(main()) Output
await asyncio.sleep(0.45) ticks while it ran: 4 a 0.45 s loop with no await ticks while it ran: 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 stalled_loop.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
During await asyncio.sleep(0.45) the ticker ran four times, as it should. During a loop of 0.45 seconds with no
await in it, standing in for parsing a big file or resizing an image, the ticker did not run once. Step through
both measurements on a clock and watch which code holds the thread:
The drawing shows the whole scene; its 9 steps are listed below.
Every step as text
- The ticker starts. measure() creates the ticker task and awaits asyncio.sleep(0), so the ticker gets a turn. It asks to be woken in 0.1 s and pauses.
- main() awaits. main() reaches await asyncio.sleep(0.45). It is parked until 0.45 s, and the event loop has the thread to itself.
- 0.1 s, tick 1. The ticker's timer is due, so the loop resumes it. It records tick 1 and asks to sleep for another 0.1 s.
- 0.2 to 0.4 s, ticks 2 to 4. The same happens at 0.2, 0.3 and 0.4 seconds, each time at once, because nothing else holds the thread.
- 0.45 s, 4 ticks. main() is woken at 0.45 s, cancels the ticker and prints 4 ticks. Every timer ran on time.
- A new ticker. The second measurement starts a fresh ticker the same way. It too asks to be woken in 0.1 s.
- main() computes. main() calls compute_for(0.45), a loop with no await in it. The thread is busy running it, so the event loop cannot run at all.
- 0.1 to 0.4 s, no tick. The ticker's timer is due at 0.1 s, then overdue, but only the loop can resume the ticker, and the loop cannot run until main() gives the thread back.
- 0.45 s, 0 ticks. compute_for() returns, and main() cancels the ticker before the loop gets a single turn, so it prints 0 ticks. In a web server, the ticker is every other request.
In a web server, the ticker is every other request: they all wait until the busy one gives the loop back. A
blocking call: A call that holds its thread until it finishes, such as time.sleep() or a network client not written for asyncio. Inside a coroutine it stops the event loop, and with it every other task, for as long as it runs. does the same damage as a busy loop. time.sleep() stops the whole
thread, and the event loop runs in that thread:
"""time.sleep() inside a coroutine blocks the whole event loop (recorded on CPython)."""
import asyncio
import time
async def ticker(ticks):
while True:
await asyncio.sleep(0.1)
ticks.append(time.perf_counter())
async def blocking_wait():
time.sleep(0.45) # a blocking call: the thread sleeps, and the event loop with it
async def main():
ticks = []
tick_task = asyncio.create_task(ticker(ticks))
await asyncio.sleep(0)
await blocking_wait()
tick_task.cancel()
print(f"time.sleep(0.45) in a coroutine ticks while it ran: {len(ticks)}")
asyncio.run(main()) Output
time.sleep(0.45) in a coroutine ticks while it ran: 0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 blocking_sleep.py
Note
This program has no Run button. Python in the browser implements time.sleep() by pausing through the browser’s own
scheduler, which lets other tasks run during the sleep (Pyodide’s changelog describes the change), so the browser
would show four ticks where CPython shows none. The recorded run above is what CPython does on a server or a laptop.
The usual blocking calls in real code are time.sleep(), the HTTP clients and database drivers that are not written
for asyncio, reading large files, and long computations. Inside a coroutine, use their async versions:
await asyncio.sleep() instead of time.sleep(), an async HTTP client instead of a blocking one. Work that cannot be
made async goes to a thread or a process, with asyncio.to_thread() or an executor, which a later lesson of this
module covers.
Key idea
The loop can switch tasks only at an await, so anything that runs without one, a busy loop or a blocking call,
holds up every other task for as long as it runs.
Checkpoint
Check: A blocking call stalls every task
A coroutine called without await never runs
The most common asyncio mistake costs no error at all. Calling a coroutine function without await creates a
coroutine object and throws it away, so its body never runs:
"""Calling a coroutine function without await: the call does nothing, and Python warns."""
import asyncio
import gc
import warnings
saved = []
async def save(order):
await asyncio.sleep(0) # stands in for a database write
saved.append(order)
async def main():
save("order-1") # the mistake: no await, so this only creates a coroutine object
await save("order-2")
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
asyncio.run(main())
gc.collect() # make sure the forgotten coroutine object has been cleaned up
print("orders saved:", saved)
for warning in caught:
print(f"{warning.category.__name__}: {warning.message}") Output
orders saved: ['order-2'] RuntimeWarning: coroutine 'save' was never awaited
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 forgot_await.py
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Your run, in this browser
Only the second order was saved. The first call made a coroutine that nobody ran, and when Python destroyed it, it
issued a RuntimeWarning naming the coroutine. In a normal run the warning goes to standard error, with the file and
line of the call; the example collects it with warnings.catch_warnings() only to print it neatly. Treat the message
“coroutine … was never awaited” as an error. Asyncio’s debug mode, asyncio.run(main(), debug=True), adds where the
coroutine was created, and mypy reports a coroutine whose value is not used, its unused-coroutine check, before the
code even runs.
Calling a coroutine function like a plain function
save("order-1") on a line of its own makes a coroutine object and drops it, so the order is never saved. Write
await save("order-1") to run it as part of the current task, or pass it to asyncio.create_task() to run it as a
task of its own, as the next lesson shows.
Checkpoint
Check: The forgotten await
asyncio pays off when the time goes to waiting
Two questions decide most cases: where a job’s time goes, and whether async libraries exist for what it calls. The table sets the two sides against each other:
| Criterion | Good fit | Poor fit |
|---|---|---|
| Where the time goes | waiting for networks, databases and other services | computing: parsing, maths, image work |
| How many things at once | hundreds or thousands of connections | a few heavy jobs |
| Libraries | async clients for HTTP, databases and queues exist | only blocking libraries are available |
| Typical programs | crawlers, chat and websocket servers, API fan-out, bots | batch number crunching, video encoding |
| When to choose | asyncio tasks in one thread | processes, or threads for blocking libraries |
Use asyncio when a program spends most of its time waiting and the waits can overlap: a crawler fetching thousands of pages, a chat server holding many idle connections, a service that calls five other services for every request. Do not use it to speed up computation. Two calculations in two tasks still run one after the other on one core, and each blocks the loop while it runs. For CPU-bound work use processes, and for blocking libraries use threads, as the concurrency module of this track measures.
Split one job before you choose
Time a job’s waiting and its computing apart. The waiting overlaps on a loop, so a thousand jobs wait about as long as one; the computing adds up job by job, so a thousand jobs of 10 ms each still need 10 seconds of CPU.
Checkpoint
Check: Jobs that asyncio suits
Practise: make breakfast in the time of the slowest step
The exercise puts the lesson to work in a kitchen. Its starter code runs the steps of a breakfast one after another, so the breakfast takes as long as all of them together; start the steps as tasks so that it takes about as long as its slowest one. The sample tests run in your browser:
Exercise · Easy · Python
Make breakfast in the time of the slowest step
Write make_breakfast(steps, wait) in breakfast.py, a coroutine that runs every step of a breakfast at the same time, so that the whole breakfast takes about as long as its slowest step.
stepsis a dictionary from a step's name to its duration in minutes, for example{"boil water": 4, "toast bread": 2, "fry eggs": 3}.waitis a coroutine function:await wait(name, minutes)waits for one step. The tests pass in a fake one, so nothing really waits; in a real program it could beasyncio.sleep.- Return the names in the order the steps finished:
["toast bread", "fry eggs", "boil water"]for the example. Steps of equal length finish in the order the dictionary lists them. - An empty dictionary gives an empty list.
The starter code runs the steps one after another. Its result is right for a single step, but it takes the sum of all the steps instead of the longest one.
Starter code · breakfast.py
async def make_breakfast(steps, wait):
"""Run every step at the same time; return the step names in the order they finished."""
finished = []
for name, minutes in steps.items():
await wait(name, minutes) # one step at a time: the breakfast takes the sum of the steps
finished.append(name)
return finished The sample tests · test_breakfast.py
import asyncio
from breakfast import make_breakfast
class FakeKitchen:
"""A fake wait(): a step of n minutes lasts n turns of the event loop, so nothing waits for real. It records
when each step starts and ends, so the tests can see which steps ran at the same time."""
def __init__(self):
self.events = []
async def wait(self, name, minutes):
self.events.append(("start", name))
for _ in range(minutes):
await asyncio.sleep(0)
self.events.append(("end", name))
def started_before_any_ended(self):
kinds = [kind for kind, _ in self.events]
return kinds.index("end") if "end" in kinds else len(kinds)
async def test_finish_order():
"""the steps finish shortest first"""
kitchen = FakeKitchen()
steps = {"boil water": 4, "toast bread": 2, "fry eggs": 3}
assert await make_breakfast(steps, kitchen.wait) == ["toast bread", "fry eggs", "boil water"]
async def test_all_steps_start_together():
"""every step starts before the first one ends"""
kitchen = FakeKitchen()
await make_breakfast({"boil water": 4, "toast bread": 2, "fry eggs": 3, "slice fruit": 1}, kitchen.wait)
assert kitchen.started_before_any_ended() == 4
async def test_equal_steps_keep_their_order():
"""steps of equal length finish in the order they are listed"""
kitchen = FakeKitchen()
steps = {"pour juice": 1, "pour milk": 1, "warm plates": 1}
assert await make_breakfast(steps, kitchen.wait) == ["pour juice", "pour milk", "warm plates"]
async def test_one_step():
"""a single step works"""
kitchen = FakeKitchen()
assert await make_breakfast({"make tea": 3}, kitchen.wait) == ["make tea"]
async def test_no_steps():
"""an empty breakfast is an empty list"""
assert await make_breakfast({}, FakeKitchen().wait) == [] A hint
Start every step before you wait for any of them: make one task per step with asyncio.create_task(), inside a coroutine that awaits wait(name, minutes) and then appends the name to a list. Await the tasks only after all of them exist.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Then test yourself on all seven ideas. Practice starts with the idea your answers show is weakest, moves up a level when you get one right, and says why each choice is right or wrong as soon as you check it:
Practice that adapts to you
Practise this lesson
14 questions about 7 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher to 2–5 years · What is the difference between calling a coroutine function and awaiting it?
Calling a coroutine function, such as fetch(url), runs none of its body: it only creates a coroutine object, an
object that knows how to run the body later. The body runs when something drives that object: await fetch(url)
inside another coroutine runs it as part of the current task, pausing wherever it waits; asyncio.create_task() wraps
it in a task that the event loop starts on its own; and asyncio.run() starts a loop to run it from ordinary code. A
coroutine object that is never driven does nothing, and when it is garbage-collected Python issues a RuntimeWarning
saying that it was never awaited. So the call prepares the work, and the await, a task or asyncio.run() performs it.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
An async web service handles about 400 requests a second on one event loop, with a p99 latency of 40 ms. After a release, one endpoint renders a PDF report in pure Python for 300 ms per request, and the p99 of every endpoint rises above one second. Why, and what are your options?
-
Mid to staff
A nightly job downloads 20,000 small files from an object store, about 150 ms each, and parses each one, about 5 ms of CPU time. Would you use asyncio, threads or processes, and how long should the job take?
Key takeaways
async defdefines a coroutine function: calling it creates a coroutine object and runs none of its body, and only a driver,asyncio.run(), anawaitor a task, runs it, from one pause to the next.- The event loop runs one task at a time and switches only when the running task awaits something that is not ready;
await asyncio.sleep(0)gives up the turn on purpose, and awaiting a coroutine that never waits is an ordinary call. - Waits overlap: a thousand waits of 0.2 seconds finished in about 0.2 seconds in one thread, at about 1.3 KiB per waiting task, while the computing between them still runs one task at a time.
- Anything that runs without awaiting, a busy loop or a blocking call such as
time.sleep(), stalls every task on the loop. - A coroutine called without
awaitnever runs; the “was never awaited” warning is the sign. - asyncio suits many network waits; for computation use processes, and for blocking libraries threads.
Terms in this lesson
- blocking call
- A call that holds its thread until it finishes, such as time.sleep() or a network client not written for asyncio. Inside a coroutine it stops the event loop, and with it every other task, for as long as it runs.
- cooperative scheduling
- Taking turns where each task decides when to give up control. An asyncio task runs until it awaits something that is not ready, and nothing switches it out before that, so a task that never awaits holds up all the others.
- coroutine
- The object a call to an async def function returns. Driven by await, a task or asyncio.run(), it runs its body up to each await on something not ready, pauses there with its local variables kept and resumes later from the same spot.
- coroutine function
- A function defined with async def. Calling it runs none of its body: it returns a coroutine object, which runs only when something drives it, such as await, a task or asyncio.run().
- event loop
- The part of asyncio that runs tasks one at a time in one thread. It runs a task until it awaits something that is not ready, runs other ready tasks meanwhile, and resumes the first once its timer fires or its data arrives.
References
- A Conceptual Overview of asyncio (Python Software Foundation)
- asyncio: Asynchronous I/O (Python Software Foundation)
- Coroutines and Tasks: coroutines (Python Software Foundation)
- Coroutines and Tasks: sleeping (Python Software Foundation)
- Runners: asyncio.run() (Python Software Foundation)
- Event Loop (Python Software Foundation)
- Developing with asyncio: detect never-awaited coroutines (Python Software Foundation)
- Developing with asyncio: running blocking code (Python Software Foundation)
- The Python Language Reference: coroutine function definition (Python Software Foundation)
- The Python Language Reference: await expression (Python Software Foundation)
- inspect.getcoroutinestate() (Python Software Foundation)
- tracemalloc: Trace memory allocations (Python Software Foundation)
- types.coroutine() (Python Software Foundation)
- PEP 492: Coroutines with async and await syntax (Python Software Foundation)
- mypy error codes: check that coroutine return value is used (The mypy project)
- Pyodide changelog (time.sleep() and stack switching, version 0.28.0) (Pyodide)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress