Beyond the basicsChapter 90 of 114
Async Basics
Do other work while waiting, without threads.
The problem
Most programs spend their time waiting — for a network reply, a database, a file. A normal program waits and does nothing. Ten requests taking one second each take ten seconds:
import time
def fetch(name):
time.sleep(1)
return name
start = time.perf_counter()
results = [fetch(n) for n in range(10)]
print(f"{time.perf_counter() - start:.0f} seconds")Output, from a real run elsewhere
10 seconds
Async lets one thread start all ten and handle each reply as it arrives.
async def and await
An async def function is a coroutine. Calling it does not run it; it returns an object you must await:
import asyncio
async def greet():
return "hello"
coro = greet()
print(type(coro).__name__)
print(asyncio.run(coro))Output
coroutine hello
asyncio.run() starts the event loop, runs the coroutine to completion, and shuts the loop down. It is the entry point from ordinary code.
await hands control back
import asyncio
async def work(name, seconds):
print("starting", name)
await asyncio.sleep(seconds)
print("finished", name)
return name
async def main():
await work("a", 1)
await work("b", 1)
asyncio.run(main())Output
starting a finished a starting b finished b
That is still two seconds — awaiting one after the other is just waiting in order. The win comes from starting them together.
Running things at the same time
import asyncio
async def work(name, seconds):
await asyncio.sleep(seconds)
return name
async def main():
results = await asyncio.gather(
work("a", 1),
work("b", 1),
work("c", 1),
)
print(results)
asyncio.run(main())Output
['a', 'b', 'c']
Three one-second waits, one second total. gather returns the results in the order you passed them, not the order they finished.
Tasks
create_task schedules a coroutine immediately and gives you a handle:
import asyncio
async def work(name):
await asyncio.sleep(0.1)
return name
async def main():
task = asyncio.create_task(work("a"))
print("task is running while we do other things")
print(await task)
asyncio.run(main())Output
task is running while we do other things a
await only inside async def
def broken():
await asyncio.sleep(1) # SyntaxErrorAnd an async def cannot be called like a normal function — you get a coroutine object and a warning that it was never awaited. Forgetting await is the most common async bug, and the symptom is code that appears to do nothing.
It only helps with waiting
Async is concurrency, not parallelism. There is still one thread, so computation gains nothing:
import asyncio
async def compute():
return sum(range(10_000_000)) # blocks everything while it runs
async def main():
await asyncio.gather(compute(), compute())
asyncio.run(main())For processor-bound work use multiprocessing. For blocking calls you cannot avoid, hand them to a thread:
import asyncio
async def main():
result = await asyncio.to_thread(open("file.txt").read)
print(len(result))Everything in the chain has to be async
A blocking call inside a coroutine blocks the whole loop, which quietly removes the benefit:
import asyncio, time
async def bad():
time.sleep(1) # blocks the event loop
async def good():
await asyncio.sleep(1) # yields to the loopThis is why async libraries come in pairs: requests blocks, httpx and aiohttp do not.
When to use it
Use it for lots of concurrent waiting — a web server, a scraper, a client making many API calls. For a script that does three things in order, plain synchronous code is simpler and just as fast.
Test yourself
2 questionsWhat does calling an async function without await give you?
Show the answer
A coroutine object that never runs — Forgetting await is the most common async bug, and the symptom is code that seems to do nothing.
What kind of work does async speed up?
Show the answer
Waiting, such as network or disk — It is concurrency, not parallelism. For processor-bound work use multiprocessing.
NumPy Intro
Arrays that do arithmetic on every element at once.