async and await let one thread handle thousands of operations that are
mostly spent waiting. Whether they help you at all comes down to one question, so this post starts
there.
The question that decides everything
Is the work waiting, or computing?
- I/O-bound — network calls, database queries, reading files, talking to an API. The CPU is idle while something else takes its time. async helps, a lot.
- CPU-bound — parsing, resizing images, crunching numbers. The CPU is the thing
that is busy. async does nothing. Use
multiprocessing.
async is not parallelism. It is one thread that stops working on a task the moment that task starts waiting, and picks up another. If nothing ever waits, there is nothing to switch to and you have added syntax for no gain.
Coroutines
import asyncio
async def fetch(name):
await asyncio.sleep(0.01) # stands in for a network call
return f"got {name}"
result = fetch("accounts")
print(type(result).__name__) # Output: coroutine
print(asyncio.run(fetch("accounts"))) # Output: got accounts
async def makes a coroutine function. Calling it does not run it — it
returns a coroutine object, as the third line shows. Something has to drive it.
asyncio.run() is that something. It starts an event loop, runs the coroutine to
completion, and shuts down. One call, at the top of your program, and never inside a coroutine.
Forgetting to await is the characteristic beginner bug, and Python warns about it:
RuntimeWarning: coroutine 'fetch' was never awaited. If you see that, you called a
coroutine and threw the result away.
await
import asyncio
async def fetch(name, delay):
await asyncio.sleep(delay)
return name
async def main():
first = await fetch("accounts", 0.01)
second = await fetch("transactions", 0.01)
return [first, second]
print(asyncio.run(main())) # Output: ['accounts', 'transactions']
await means "suspend here, let the loop run something else, resume when this is done".
It may only appear inside an async def.
But look carefully at what this does: the second fetch does not start until the first has finished.
Two awaits in a row are sequential. Writing async everywhere and then
awaiting one thing at a time gives you all of the complexity and none of the speed.
Doing things at the same time
import asyncio
import time
async def fetch(name, delay):
await asyncio.sleep(delay)
return name
async def sequential():
return [await fetch("a", 0.1), await fetch("b", 0.1)]
async def concurrent():
return await asyncio.gather(fetch("a", 0.1), fetch("b", 0.1))
for coro in (sequential(), concurrent()):
started = time.perf_counter()
result = asyncio.run(coro)
elapsed = time.perf_counter() - started
print(result, "took about", round(elapsed, 1), "seconds")
# Output: ['a', 'b'] took about 0.2 seconds
# Output: ['a', 'b'] took about 0.1 seconds
Same work, half the time. gather() starts everything at once and waits for all of it,
returning results in the order you passed them in — not the order they finished.
This is the point of async, and it is worth being blunt about it: if your code has no
gather or task group in it, async is almost certainly buying you nothing.
Task groups
import asyncio
async def fetch(name):
await asyncio.sleep(0.01)
return f"got {name}"
async def main():
async with asyncio.TaskGroup() as group:
a = group.create_task(fetch("accounts"))
b = group.create_task(fetch("transactions"))
return [a.result(), b.result()]
print(asyncio.run(main()))
# Output: ['got accounts', 'got transactions']
TaskGroup arrived in 3.11 and is the modern replacement for gather. The
async with block does not exit until every task in it is finished, and if one raises, the
others are cancelled and the error propagates.
gather by default does the opposite — one failure leaves the others running orphaned,
which is a leak. Prefer TaskGroup for new code on 3.11 and later.
The blocking call that ruins it
import asyncio
import time
async def bad():
time.sleep(0.05) # BLOCKS the whole event loop
return "done"
async def good():
await asyncio.sleep(0.05) # yields to the loop
return "done"
async def main():
started = time.perf_counter()
await asyncio.gather(bad(), bad())
blocked = time.perf_counter() - started
started = time.perf_counter()
await asyncio.gather(good(), good())
yielded = time.perf_counter() - started
return round(blocked, 1), round(yielded, 1)
print(asyncio.run(main())) # Output: (0.1, 0.1)
Look at the numbers: gather made no difference to the blocking version, because
time.sleep stops the entire event loop rather than yielding to it. Nothing else in the
program runs — not the other task, not a web request, nothing.
This is the failure mode that catches people in production. One ordinary library call — the
requests library, a database driver that is not async, a synchronous file read — freezes
every concurrent operation in the process. The rules:
- Use async-aware libraries in async code:
httpxoraiohttp, notrequests. - Never
time.sleep()in a coroutine.await asyncio.sleep(). - When you must call something blocking, push it to a thread:
await asyncio.to_thread(slow_function, arg).
What it looks like in real code
Everything above used asyncio.sleep to stand in for real waiting. This is the shape with
an actual HTTP client — it needs pip install httpx, so it is not executed here:
import asyncio
import httpx
async def fetch(client, url):
response = await client.get(url)
return url, response.status_code
async def main(urls):
async with httpx.AsyncClient(timeout=10) as client:
async with asyncio.TaskGroup() as group:
tasks = [group.create_task(fetch(client, u)) for u in urls]
return [t.result() for t in tasks]
results = asyncio.run(main([
"https://example.com",
"https://example.org",
]))
Two async with blocks, nested for different reasons. The outer one manages the client's
connection pool — sharing one client across every request is what makes this fast, and creating a fresh
client per request throws that away. The inner one is the task group.
Fifty URLs there take roughly as long as the slowest one rather than the sum of all fifty, and that ratio is the entire reason to write any of this.
Should you use it
Async is genuinely infectious: a coroutine can only be awaited by another coroutine, so one async function tends to turn the call chain above it async as well. That cost is real and worth paying only when you are doing many things that wait.
Reach for it when you are making hundreds of network calls, writing a web service that talks to other services, or handling many slow connections at once. Skip it for a script that makes three HTTP requests — threads are simpler, or just do them in order.
Next: Testing.