Warm-up · Activity 1 of 8
// A3.4 · ~40 min · Advanced
asyncio: coroutines and tasks
After this lesson you write coroutines, run them from a normal program, overlap many waits in one thread, spot the blocking call that freezes everything, and run other programs without blocking.
You will be able to
- Write coroutines with async def and await, and run them with asyncio.run
- Run coroutines concurrently with create_task and gather and collect their results
- Explain where the event loop switches tasks and why a blocking call stalls it
- Run another program from a coroutine with create_subprocess_exec and read its output
Predict · Activity 2 of 8
Predict before you read on. greet is called but never awaited. What does this print?
import asyncio async def greet(): print("hello") return 42 result = greet() print(type(result).__name__) result.close()Practice · Activity 3 of 8
Fill in the asyncio function that starts an event loop, runs main to the end and returns its result.
import asyncio async def main(): await asyncio.sleep(0) return "ran" print(asyncio.____(main()))print(asyncio.(main()))Practice · Activity 4 of 8
create_task schedules say("B"). What does this print?
import asyncio async def say(word): print(word, end=" ") async def main(): task = asyncio.create_task(say("B")) print("A", end=" ") await task print("C") asyncio.run(main())Practice · Activity 5 of 8
Match each piece of asyncio to what it does.
Practice · Activity 6 of 8
The coroutine starts a second Python that prints hi. Only standard output is piped. What does this print?
import asyncio import sys async def main(): process = await asyncio.create_subprocess_exec( sys.executable, "-c", "print('hi')", stdout=asyncio.subprocess.PIPE, ) out, err = await process.communicate() print(out, err, process.returncode) asyncio.run(main())Brain teaser · Activity 7 of 8
Teaser. The slow job is listed first. What does this print?
import asyncio async def job(name, delay): await asyncio.sleep(delay) print("done", name, end="; ") return name async def main(): print(await asyncio.gather(job("slow", 0.02), job("fast", 0.01))) asyncio.run(main())Apply · Activity 8 of 8
Mini task, on your own machine. Write a coroutine fetch(name, delay) that awaits asyncio.sleep(delay) and returns name.upper(). In main, gather three fetches of 0.1 s each for "home", "about" and "blog", and print the list together with whether the whole gather took less than 0.2 s, measured with time.perf_counter. Run it with asyncio.run.
Check your work against this list
Build it yourself
Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.
Worked example
Three fetches, three ways
fetch pretends to download a page by awaiting asyncio.sleep(0.1). The program runs three fetches three ways, each under its own asyncio.run: awaited one by one, passed to gather, and started with create_task. The start and end lines show when each coroutine gives control back, and the timings show the waits overlapping. Save it as main.py and run python main.py (python3 main.py on macOS and Linux) on your own machine.
main.py
import asyncio
import time
from collections.abc import Coroutine
from typing import Any
async def fetch(name: str) -> str:
"""Pretend to download a page: the await hands control back to the event loop."""
print(" start", name)
await asyncio.sleep(0.1) # waiting on the network
print(" end", name)
return name.upper()
async def one_by_one(names: list[str]) -> list[str]:
return [await fetch(name) for name in names]
async def all_at_once(names: list[str]) -> list[str]:
return await asyncio.gather(*(fetch(name) for name in names))
async def with_tasks(names: list[str]) -> list[str]:
tasks = [asyncio.create_task(fetch(name)) for name in names]
print(" tasks created, none started yet")
return [await task for task in tasks]
def timed(label: str, coro: Coroutine[Any, Any, list[str]]) -> None:
print(label)
start = time.perf_counter()
result = asyncio.run(coro)
print(f" {result}, done in under 0.2 s: {time.perf_counter() - start < 0.2}")
if __name__ == "__main__":
names = ["a", "b", "c"]
timed("await one by one:", one_by_one(names))
timed("gather:", all_at_once(names))
timed("create_task:", with_tasks(names))
Run it with
python main.pyOutput
await one by one:
start a
end a
start b
end b
start c
end c
['A', 'B', 'C'], done in under 0.2 s: False
gather:
start a
start b
start c
end a
end b
end c
['A', 'B', 'C'], done in under 0.2 s: True
create_task:
tasks created, none started yet
start a
start b
start c
end a
end b
end c
['A', 'B', 'C'], done in under 0.2 s: True- Awaiting one by one finishes each fetch before starting the next: three waits of 0.1 s take 0.3 s.
- gather and create_task start all three before any ends, so the waits overlap into about 0.1 s.
- The tasks did not start when create_task was called; they started when with_tasks first awaited.
- Everything ran in one thread: the switches happened only at await asyncio.sleep.
Exercises
Exercise 1 of 3
Fetch them all at once
fetch_all(names, fetch) awaits one fetch after another, so the waits add up. Change it to run every fetch(name) concurrently with asyncio.gather, and still return a dict from each name to its text in the order of names. fetch is any coroutine function taking a name. Run the tests on your own machine.
This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.
Hints
Hint 1
fetch(name) without await creates a coroutine; a generator expression (fetch(name) for name in names) makes one per name.
Hint 2
Unpack them into gather: await asyncio.gather(*coroutines). The results come back in the same order as the names.
Hint 3
dict(zip(names, texts)) pairs each name with its text.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import asyncio
from collections.abc import Awaitable, Callable
async def fetch_all(names: list[str], fetch: Callable[[str], Awaitable[str]]) -> dict[str, str]:
"""Run fetch(name) for every name concurrently; map each name to its text, in the order of names."""
texts = await asyncio.gather(*(fetch(name) for name in names))
return dict(zip(names, texts))
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import asyncio
from collections.abc import Awaitable, Callable
async def fetch_all(names: list[str], fetch: Callable[[str], Awaitable[str]]) -> dict[str, str]:
"""Run fetch(name) for every name concurrently; map each name to its text, in the order of names."""
# Each await waits for one fetch before the next one starts.
# Start them all together with asyncio.gather instead.
return {name: await fetch(name) for name in names}
test_main.py
import asyncio
from main import fetch_all
def test_results():
"""Every name maps to its text"""
async def fake_fetch(name):
await asyncio.sleep(0)
return name * 2
got = asyncio.run(fetch_all(["a", "b"], fake_fetch))
assert got == {"a": "aa", "b": "bb"}, f"fetch_all returned {got!r}"
def test_order_of_names():
"""The dict follows the order of names, even when a later fetch finishes first"""
async def fake_fetch(name):
await asyncio.sleep(0.02 if name == "slow" else 0)
return name.upper()
got = asyncio.run(fetch_all(["slow", "fast"], fake_fetch))
assert list(got.items()) == [("slow", "SLOW"), ("fast", "FAST")], f"fetch_all returned {got!r}"
def test_fetches_overlap():
"""All three fetches are waiting at the same time"""
running = 0
peak = 0
async def fake_fetch(name):
nonlocal running, peak
running += 1
peak = max(peak, running)
await asyncio.sleep(0)
running -= 1
return name
asyncio.run(fetch_all(["a", "b", "c"], fake_fetch))
assert peak == 3, f"at most {peak} fetch(es) waited at once: start them all with asyncio.gather"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 2 of 3
Unblock the kitchen
brew_all starts one task per order, but brew calls time.sleep, which blocks the whole event loop: each drink brews start to end before the next one even starts. Fix brew so that the drinks brew at the same time and a shorter brew ends first. Keep the log entries and the return values as they are. Run the tests on your own machine.
This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.
Hints
Hint 1
The event loop can switch tasks only at an await that suspends. time.sleep never gives control back.
Hint 2
asyncio.sleep(seconds) is the awaitable version: await it instead of calling time.sleep.
Hint 3
Once time.sleep is gone, the import time line is no longer needed.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import asyncio
async def brew(name: str, seconds: float, log: list[str]) -> str:
"""Pretend to brew a drink for some seconds; log when it starts and ends."""
log.append(f"{name} start")
await asyncio.sleep(seconds) # suspends this task; the others run meanwhile
log.append(f"{name} end")
return f"{name} ready"
async def brew_all(orders: dict[str, float]) -> tuple[list[str], list[str]]:
"""Brew every order in its own task; return (results in order, log)."""
log: list[str] = []
tasks = [asyncio.create_task(brew(name, seconds, log)) for name, seconds in orders.items()]
results = [await task for task in tasks]
return results, log
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import asyncio
import time
async def brew(name: str, seconds: float, log: list[str]) -> str:
"""Pretend to brew a drink for some seconds; log when it starts and ends."""
log.append(f"{name} start")
time.sleep(seconds) # blocks the event loop: no other task can run meanwhile
log.append(f"{name} end")
return f"{name} ready"
async def brew_all(orders: dict[str, float]) -> tuple[list[str], list[str]]:
"""Brew every order in its own task; return (results in order, log)."""
log: list[str] = []
tasks = [asyncio.create_task(brew(name, seconds, log)) for name, seconds in orders.items()]
results = [await task for task in tasks]
return results, log
test_main.py
import asyncio
from main import brew_all
def test_results():
"""Every order is ready, in the order of the orders"""
results, _ = asyncio.run(brew_all({"tea": 0.01, "coffee": 0}))
assert results == ["tea ready", "coffee ready"], f"brew_all returned {results!r}"
def test_brews_overlap():
"""Coffee starts while tea is still brewing, and the shorter brew ends first"""
_, log = asyncio.run(brew_all({"tea": 0.02, "coffee": 0.01}))
assert log == ["tea start", "coffee start", "coffee end", "tea end"], f"the log was {log!r}: does brew block the event loop?"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyExercise 3 of 3
Run programs side by side
Write run_python(code): it starts the Python you are running, sys.executable, with the arguments "-c" and code, standard output piped, and returns its exit status and its decoded output without the last newline. Then change run_all so that all programs run at the same time, with the results in the order of snippets. On your own machine, python main.py runs a demo and python learnrun.py test runs the tests.
This exercise needs Python on your computer (the browser version cannot run it). The files and commands are below.
Hints
Hint 1
process = await asyncio.create_subprocess_exec(sys.executable, "-c", code, stdout=asyncio.subprocess.PIPE) starts the program; each argument is its own string.
Hint 2
out, _ = await process.communicate() waits for the end. out is bytes: out.decode().rstrip("\n") gives the text, and process.returncode the exit status.
Hint 3
For run_all, pass one run_python(code) per snippet to asyncio.gather, as in exercise 1.
Show a solution
One way to solve it. Yours can look different and still pass the checks.
import asyncio
import sys
async def run_python(code: str) -> tuple[int, str]:
"""Run code in a new Python process; return its exit status and its output without the last newline."""
process = await asyncio.create_subprocess_exec(sys.executable, "-c", code, stdout=asyncio.subprocess.PIPE)
out, _ = await process.communicate()
assert process.returncode is not None # set once the program has ended
return process.returncode, out.decode().rstrip("\n")
async def run_all(snippets: list[str]) -> list[tuple[int, str]]:
"""Run every snippet at the same time; return the results in the order of snippets."""
return await asyncio.gather(*(run_python(code) for code in snippets))
if __name__ == "__main__":
print(asyncio.run(run_all(["print('hello')", "import sys; sys.exit(3)"])))
Run it on your computer
Install Python 3.14 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.
main.py
import asyncio
import sys
async def run_python(code: str) -> tuple[int, str]:
"""Run code in a new Python process; return its exit status and its output without the last newline."""
# TODO: start sys.executable with the arguments "-c" and code, standard output piped;
# await communicate(), then return the exit status and the decoded output.
raise NotImplementedError
async def run_all(snippets: list[str]) -> list[tuple[int, str]]:
"""Run every snippet at the same time; return the results in the order of snippets."""
# This waits for each program before it starts the next one.
return [await run_python(code) for code in snippets]
if __name__ == "__main__":
print(asyncio.run(run_all(["print('hello')", "import sys; sys.exit(3)"])))
test_main.py
import asyncio
import time
from main import run_all, run_python
def test_output():
"""run_python returns exit status 0 and what the program printed"""
got = asyncio.run(run_python("print('hi')"))
assert got == (0, "hi"), f"run_python returned {got!r}, expected (0, 'hi')"
def test_exit_status():
"""A program that ends with sys.exit(3) gives exit status 3"""
got = asyncio.run(run_python("import sys; sys.exit(3)"))
assert got == (3, ""), f"run_python returned {got!r}, expected (3, '')"
def test_programs_overlap():
"""Three programs of 0.5 s each run at the same time, and the results keep their order"""
snippets = [f"import time; time.sleep(0.5); print({n})" for n in range(3)]
started = time.perf_counter()
got = asyncio.run(run_all(snippets))
took = time.perf_counter() - started
assert got == [(0, "0"), (0, "1"), (0, "2")], f"run_all returned {got!r}"
assert took < 1.4, f"run_all took {took:.2f} s: start every program before you wait for any"
On macOS and Linux, type python3 wherever these commands say python, as in the first lesson.
Run the program:
python main.pyRun the checks (needs learnrun.py in the same folder):
python learnrun.py testDownload learnrun.pyCommon mistakes
Calling asyncio.run inside a coroutine
import asyncio
async def inner() -> int:
return 1
async def main() -> None:
print(asyncio.run(inner()))
asyncio.run(main())
What Python prints
RuntimeError: asyncio.run() cannot be called from a running event loopWhy, and the fix
main already runs inside the event loop that the outer asyncio.run started, and asyncio.run refuses to start a second one in the same thread. Inside a coroutine, write await inner(). Keep a single asyncio.run as the entry point of the program.
Forgetting await
import asyncio
async def fetch_number() -> int:
await asyncio.sleep(0)
return 41
async def main() -> None:
total = fetch_number() + 1
print(total)
asyncio.run(main())
What Python prints
TypeError: unsupported operand type(s) for +: 'coroutine' and 'int'Why, and the fix
fetch_number() without await is a coroutine object, not 41, and it never runs. Write total = await fetch_number() + 1: await binds tighter than +, so the coroutine runs first. A type checker such as mypy reports this line before you run it.
Passing the function instead of a coroutine
import asyncio
async def main() -> None:
print("hello")
asyncio.run(main)
What Python prints
TypeError: An asyncio.Future, a coroutine or an awaitable is requiredWhy, and the fix
asyncio.run needs something it can await: the coroutine that main() returns, not the function main. Write asyncio.run(main()).
Passing a whole command line as one string
import asyncio
async def main() -> None:
process = await asyncio.create_subprocess_exec("python -c print(1)")
await process.communicate()
asyncio.run(main())
What Python prints
FileNotFoundError: [Errno 2] No such file or directory: 'python -c print(1)'Why, and the fix
create_subprocess_exec uses no shell, so nothing splits the string at spaces: it looks for a program whose name is the whole string. Pass the program and every argument as separate strings: create_subprocess_exec(sys.executable, "-c", "print(1)").
Python in the browser: Pyodide 314.0.7, MPL-2.0. Licence and source
Exit ticket
5 questions, no hints. Score 80% or more to complete the lesson.
Finish every activity above to unlock the exit ticket.