Threads let your program wait for several things at once, but each thread
costs memory, and the operating system decides when to switch between them.
asyncio runs many tasks in a single thread, and switches between them only
at the points you mark with await.
asyncio is a good fit when a program waits on many things at once, such as hundreds of HTTP requests or a chat server with thousands of open connections.
Coroutines and await
Put async in front of def and you get a coroutine function:
import asyncio
async def fetch(url: str) -> str:
await asyncio.sleep(0.5) # stands in for a network call
return f"<html>{url}</html>"
Calling fetch("a") does not run it. It returns a coroutine, an object
that runs when something awaits it. await runs a coroutine and pauses the
current one until that coroutine finishes. Only code inside an async def
function can use await.
asyncio.run() starts the event loop, the scheduler that runs
coroutines, and runs one coroutine until it finishes:
async def main() -> None:
page = await fetch("a")
print(page)
asyncio.run(main())
Call asyncio.run() once, at the top of your program. Everything below it
is async def functions awaiting each other.
Awaiting one call at a time is still sequential
This version downloads three pages, but it is no faster than ordinary code:
async def main() -> None:
a = await fetch("a")
b = await fetch("b") # starts only after "a" is done
c = await fetch("c")
Each await waits for its download before the next line runs. To run the
three at the same time, pass them all to asyncio.gather:
async def main() -> None:
a, b, c = await asyncio.gather(fetch("a"), fetch("b"), fetch("c"))
gather starts every coroutine you give it, waits for all of them, and
returns their results in the order you passed them. The three downloads
overlap, so the whole thing takes about 0.5 seconds instead of 1.5.
How the switching works
When a coroutine awaits something that is not ready, such as a network reply
or asyncio.sleep, it pauses and hands control back to the event loop. The
loop then runs another coroutine that is ready to continue. When the reply
arrives, the loop resumes the paused coroutine where it left off.
So asyncio switches only at an await. Between two awaits, no other
coroutine runs, so your code is never interrupted there. A value you read
before an await can still change by the time the code after it runs.
Never block the event loop
Everything runs in one thread. If a coroutine calls something that blocks,
such as time.sleep(1) or a synchronous HTTP library, nothing else runs
until that call returns.
async def bad() -> None:
time.sleep(1) # blocks the whole program for 1 second
async def good() -> None:
await asyncio.sleep(1) # pauses only this coroutine
Inside asyncio code, use async libraries for I/O, such as httpx.AsyncClient
or aiohttp for HTTP. When you have to call blocking code,
await asyncio.to_thread(blocking_function, arg) runs it on a worker thread
so the loop can keep running other coroutines. A later exercise covers this.
Tasks run in the background
asyncio.create_task(coro) schedules a coroutine on the event loop and
returns a task right away, without waiting for it. Await the task later
to get its result:
async def main() -> None:
task = asyncio.create_task(fetch("a"))
... # do other work while "a" downloads
page = await task
Keep a reference to every task you create, and await it at some point. Later
items in this track explain why, and introduce asyncio.TaskGroup, which
waits for every task it starts.
Check your understanding
Inside an async def function, you call fetch("a") but forget the await. What happens?
Show answer
Hide answer
The download never runs. Calling a coroutine function only creates a
coroutine object. When Python discards a coroutine that nothing awaited, it
warns "coroutine 'fetch' was never awaited". Add await, or pass the coroutine to
asyncio.gather or asyncio.create_task.