Asyncio
Thousands of waits, one thread.
Ava's website now answers thousands of customers at once. Giving every customer a thread would waste memory. A waiter in a restaurant does not stand next to one table until the guests finish eating. They take an order, move to another table, and come back when the food is ready. Asyncio works like that waiter.

- Thousands of customers at once
- One worker, many waiting tasks
- Switch tasks while waiting
async and await
An async def function is a coroutine. Calling it does not run it. You await it, which means, pause here, and let others work until this finishes. The asyncio dot run function starts the event loop, that coordinates everything. Here, one coroutine waits a little, and returns a message.
import asyncio
async def fetch(name):
await asyncio.sleep(0.2)
return f"{name} ready"
async def main():
print(await fetch("fern"))
asyncio.run(main())fern ready
Running many at once with gather
The real power is gather. It starts many coroutines at once, and waits for them all. Each one sleeps, but they overlap, so five waits take about the time of one. Compare the total time to the sequential version, and see the difference. One thread, and no locks.
import asyncio, time
async def check(name):
await asyncio.sleep(0.2)
return f"{name} ok"
async def main():
start = time.perf_counter()
results = await asyncio.gather(*(check(n) for n in "abcde"))
print(results)
print("seconds:", round(time.perf_counter() - start, 1))
asyncio.run(main())['a ok', 'b ok', 'c ok', 'd ok', 'e ok'] seconds: 0.2
Tasks, timeouts and errors
You can create tasks that run in the background, and set a timeout, so that a slow service cannot freeze everything. Wait for with a timeout raises an error, if the work takes too long, and you catch it. Real programs use these tools to stay responsive, even when other systems are slow.
import asyncio
async def slow():
await asyncio.sleep(1)
return "done"
async def main():
try:
r = await asyncio.wait_for(slow(), timeout=0.1)
except asyncio.TimeoutError:
print("too slow, giving up")
task = asyncio.create_task(asyncio.sleep(0.05, "bg"))
print(await task)
asyncio.run(main())too slow, giving up bg
Which tool when?
So how do you choose? For many waiting jobs, with modern libraries that support it, asyncio scales best. For waiting jobs with ordinary libraries, use threads. For heavy calculations, use processes. And for simple scripts, sequential code is perfectly fine. Measure before you optimise, because simple code is easier to maintain.
| Situation | Best tool |
|---|---|
| Thousands of network waits | asyncio |
| A few waits, ordinary libraries | threads |
| Heavy number crunching | processes |
| Simple script | plain code |
Concurrent fetches
Your turn. Write an async function that sleeps briefly, and returns a name. Use gather, to run three of them at once, and print the list of results. Ready? Here is one solution. A coroutine, a gather with three calls, and asyncio run.
Write an async fetch(name) that sleeps 0.1s and returns the name; gather three and print the list.
Show hint
await asyncio.gather(...)
Show solution
import asyncio
async def fetch(name):
await asyncio.sleep(0.1)
return name
async def main():
print(await asyncio.gather(fetch("a"), fetch("b"), fetch("c")))
asyncio.run(main())['a', 'b', 'c']
Summary
Excellent. You understand async and await, you can run many coroutines with gather, and you can add timeouts and background tasks. With threads, processes and asyncio, you can pick the right tool for any waiting or heavy job. Next, we store data properly, in a real database.
Key takeaways- async def + await
- asyncio.gather runs coroutines together
- Choose: asyncio, threads, processes