async and await ⚡
The last piece of modern Python. It looks like magic, it is really just the generator idea from Lesson 34 wearing a very good suit.
The idea
A thread waiting on the network is a thread doing nothing, holding several megabytes of stack. async replaces that with a coroutine: a function that can pause itself at a marked point, hand control back, and be resumed later. Thousands of them fit in the memory one thread would use, and they all take turns on a single thread.
Your first coroutine
import asyncio
async def greet(name):
"""async def makes a coroutine function."""
await asyncio.sleep(0.01) # pause here, let others run
return f"Hello, {name}"
async def main():
result = await greet("Guybrush")
print(result)
asyncio.run(main())
Hello, Guybrush
| Word | Means |
|---|---|
async def | This function is a coroutine; calling it returns a coroutine object, it does not run |
await | Pause here until that finishes, and let other tasks use the time |
asyncio.run(...) | Start the event loop and run this until it is done |
import asyncio
async def greet(name):
return f"Hello, {name}"
coro = greet("Elaine")
print(type(coro))
print(asyncio.run(coro))
<class 'coroutine'>
Hello, Elaine
The payoff: doing many things at once
import asyncio, time
async def fetch(page):
await asyncio.sleep(0.1) # pretend network delay
return f"page {page}"
async def one_at_a_time():
return [await fetch(n) for n in range(5)]
async def all_at_once():
return await asyncio.gather(*(fetch(n) for n in range(5)))
start = time.perf_counter()
asyncio.run(one_at_a_time())
sequential = time.perf_counter() - start
start = time.perf_counter()
results = asyncio.run(all_at_once())
concurrent = time.perf_counter() - start
print(results)
print(f"sequential about 0.5s: {0.4 < sequential < 0.9}")
print(f"concurrent about 0.1s: {concurrent < 0.3}")
['page 0', 'page 1', 'page 2', 'page 3', 'page 4']
sequential about 0.5s: True
concurrent about 0.1s: True
Look carefully at the first version. It has await in it, and it is still sequential. Every await stops and waits for that one call.
await means 'wait for this'. It does not mean 'do this in the background'. To get concurrency you must start several things before awaiting any of them, which is what gather does. Nearly every async performance complaint comes down to this misunderstanding.
Tasks: starting work in the background
import asyncio
async def work(name, seconds):
await asyncio.sleep(seconds)
print(f" {name} done")
return name
async def main():
slow = asyncio.create_task(work("slow", 0.2)) # starts immediately
fast = asyncio.create_task(work("fast", 0.05))
print("both are now running")
results = [await fast, await slow]
return results
print(asyncio.run(main()))
both are now running
fast done
slow done
['fast', 'slow']
TaskGroup: the modern, safer way
import asyncio
async def fetch(page):
await asyncio.sleep(0.01)
if page == 3:
raise ValueError("page 3 is missing")
return f"page {page}"
async def main():
failures = []
try:
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(fetch(n)) for n in range(5)]
except* ValueError as errors:
failures.extend(errors.exceptions) # note: no `return` in here
if failures:
print(f"caught {len(failures)} failure(s):", failures[0])
return "aborted"
return [t.result() for t in tasks]
print(asyncio.run(main()))
caught 1 failure(s): page 3 is missing
aborted
TaskGroup (Python 3.11+) guarantees that every task finishes or is
cancelled before the block exits, and it collects failures into an
ExceptionGroup caught with except*. Before this existed it was
easy to leave orphaned tasks running silently after an error. Prefer it to bare
gather in new code.
One rule that catches people: return, break and
continue are not allowed inside an except* block,
because an exception group can trigger several handlers and Python refuses to guess which
return wins. Collect what you need into a variable, then act on it after the block, which
is what the example above does.
Timeouts, which you will always need
import asyncio
async def slow():
await asyncio.sleep(10)
return "eventually"
async def main():
try:
async with asyncio.timeout(0.05):
return await slow()
except TimeoutError:
return "gave up waiting"
print(asyncio.run(main()))
gave up waiting
The rules of the road
- async is contagious. To
awaitsomething you must be in anasync def, whose caller must await it too, all the way up toasyncio.run. People call this "function colouring", and it is the main complaint about async in every language that has it. - Never block inside a coroutine. One
time.sleep(5)or one ordinaryrequests.getfreezes the entire event loop, and every other task with it. Use the async equivalents. - Your libraries must cooperate.
requestsis blocking; you want httpx or aiohttp. Databases need async drivers too.
import asyncio, time
async def blocking_mistake():
time.sleep(0.1) # WRONG: freezes everything
return "blocked"
async def correct():
await asyncio.sleep(0.1) # right: yields control
return "yielded"
async def escape_hatch():
"""When you must call blocking code, push it to a thread."""
return await asyncio.to_thread(time.sleep, 0.01) or "ran in a thread"
async def main():
return [await correct(), await escape_hatch()]
print(asyncio.run(main()))
['yielded', 'ran in a thread']
Async iteration
import asyncio
async def stream_pages(count):
"""An async generator: yields values as they become available."""
for n in range(count):
await asyncio.sleep(0.01)
yield f"page {n}"
async def main():
async for page in stream_pages(3):
print("received", page)
results = [p async for p in stream_pages(2)]
return results
print(asyncio.run(main()))
received page 0
received page 1
received page 2
['page 0', 'page 1']
This is exactly how you will consume a streaming response from a language model in
Level 6: tokens arrive one at a time, and async for processes each as it
lands rather than waiting for the whole reply.
Threads or async?
| Situation | Choose | Because |
|---|---|---|
| Fewer than about 100 concurrent waits | Threads | Simpler, and works with every library |
| Thousands of concurrent connections | async | Coroutines cost bytes; threads cost megabytes |
| A web server or API client | async | The whole ecosystem is built for it now |
| Existing blocking libraries you cannot replace | Threads | Async needs async-aware libraries |
| CPU-heavy work | Neither: processes | Async gives you zero extra CPU (Lesson 39) |
| It is already fast enough | Neither | Async makes code harder to read and debug. Earn it |
Async is not faster at doing work. It is better at waiting. If your program spends its time computing rather than waiting, async will make it slower and harder to read. Measure first, exactly as in Lesson 39, and let the numbers pick the tool.
Sequential to concurrent
This takes four times longer than it needs to. Fix it.
import asyncio
async def check(site):
await asyncio.sleep(0.1)
return f"{site}: ok"
async def main():
results = []
for site in ["a.com", "b.com", "c.com", "d.com"]:
results.append(await check(site))
return resultsReveal solution
import asyncio
async def check(site):
await asyncio.sleep(0.1)
return f"{site}: ok"
async def main():
sites = ["a.com", "b.com", "c.com", "d.com"]
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(check(s)) for s in sites]
return [t.result() for t in tasks]
for line in asyncio.run(main()):
print(line)
a.com: ok
b.com: ok
c.com: ok
d.com: okThe loop awaited each check before starting the next. Creating all the tasks first lets every wait overlap, turning 0.4 seconds into 0.1.
Add a timeout and a fallback
Write a function that fetches a value but returns a default if it takes too long.
Reveal solution
import asyncio
async def fetch_slowly(delay, value):
await asyncio.sleep(delay)
return value
async def with_fallback(coro, seconds, default):
"""Await coro, or return default if it takes longer than seconds."""
try:
async with asyncio.timeout(seconds):
return await coro
except TimeoutError:
return default
async def main():
quick = await with_fallback(fetch_slowly(0.01, "live data"), 0.1, "cached")
slow = await with_fallback(fetch_slowly(1.0, "live data"), 0.05, "cached")
return quick, slow
print(asyncio.run(main()))
('live data', 'cached')Every network call in production code should have a timeout. Without one, a single unresponsive server can hold a request open until something else in the stack gives up, and that is how one slow dependency takes down a whole service.
Spot the blocking call
This async program is no faster than the sequential version. Why?
import asyncio, time
async def process(item):
time.sleep(0.1) # <- here
return item * 2
async def main():
return await asyncio.gather(*(process(n) for n in range(10)))Reveal solution
time.sleep blocks the thread. The event loop cannot switch to another task while it is running, so all ten run one after another and gather buys nothing.
import asyncio
async def process(item):
await asyncio.sleep(0.1) # yields control properly
return item * 2
async def main():
return await asyncio.gather(*(process(n) for n in range(10)))
print(asyncio.run(main()))
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]The same trap covers requests.get, ordinary file reads, and any CPU-heavy loop. If you cannot avoid blocking code, wrap it in asyncio.to_thread so it runs off the event loop.
Classes, inheritance, dataclasses, generators, decorators, context managers, functional tools, real typing, threads and async. You can now read essentially any Python codebase you encounter. Take the Level 4 quiz, then Level 5 goes outside and builds things with all of it.