Definition
A program fetching fifty pages from the internet spends most of its time waiting. It sends a request, waits for the answer, starts again, and during those waits the processor does nothing.
asyncio is the standard library module that reclaims this idle time. It supplies the event loop, the conductor deciding which coroutine moves forward and which one waits, along with the tools to start them side by side.
The async and await keywords only write an intent into the code; without asyncio to carry it out, they produce nothing. The demonstration fits in a few lines: two greetings started together.
import asyncio
async def greet(name, delay):
await asyncio.sleep(delay) # lets the loop attend to the others
print("Hello", name)
async def main():
await asyncio.gather(
greet("Alice", 2),
greet("Bob", 1),
)
asyncio.run(main()) # opens the loop, closes it at the endThis program finishes in two seconds rather than three: while the first greeting waits, the second makes progress. The total duration is no longer the sum of the waits but the longest one.
The event loop
The loop is a single counter. It moves one task forward at a time and takes control back as soon as that one hits a wait: it turns to the next task and comes back when the answer arrives.
So there is a single thread of execution, never two lines at the same instant. That has an upside: no instruction is interrupted halfway through, so no locks are needed on shared variables. Concurrency comes only from the moments when a function hands control back on purpose, at every await.
One call opens and closes that loop, asyncio.run(), and everything else lives inside it. Hence a classic mistake: an await written at file level raises a SyntaxError, for want of a loop to receive it.
The async keyword does not stop at functions: placed in front of a with or in front of a for loop, it yields a context manager and an iterator able to wait in turn.
Starting several things side by side
First attempt, first disappointment: writing two await lines in a row gains nothing. The word means "wait here", so the second call only starts once the first has finished.
Overlap has to be asked for explicitly. Here are the spellings that come up daily, and what each one changes.
| Writing | What it produces |
|---|---|
await a() then await b() | Two waits end to end, no gain |
asyncio.gather(a(), b()) | Both move forward together, results in a list |
asyncio.create_task(a()) | The work runs in the background, awaited later |
asyncio.wait_for(a(), 5) | A time limit, and an exception beyond it |
asyncio.TaskGroup() | A group cancelling everything as soon as one member fails |
The TaskGroup, added in Python 3.11, is the line to remember: it guarantees that no task outlives the block, whereas a task created by hand and never awaited vanishes quietly when the loop closes.
asyncio does not make an asynchronous library out of one that is not: the client has to be built for it, such as httpx or aiohttp instead of requests.
The blocking call trap
Everything rests on one discipline: each task has to hand control back regularly. A synchronous line that takes its time freezes the whole loop, and every other task queues up behind it.
The two lines below look alike and do nothing like the same thing.
import time
async def measure():
time.sleep(3) # pauses the whole thread, loop included
await asyncio.sleep(3) # hands control back: other tasks move onThe first one puts the program to sleep, loop included; the second warns the loop that it can look elsewhere. The same gap holds for a synchronous network request, for reading a large file or for a heavy computation, with asyncio.to_thread() as the escape hatch.
A blocking call raises no error and writes nothing in the logs. The program returns the right results, simply as slowly as a synchronous version would: the cause can take hours to find.
Which leaves the underlying choice. asyncio shines on network waits, where the program does nothing but sit idle hundreds of times over, as in a web server built on FastAPI. On pure computation it brings nothing, for want of any waiting to reclaim, and threading or separate processes remain the right answers.
Frequently asked questions
Does asyncio make a program faster?
Only if it spends its time waiting: network calls, database queries, remote reads. The gain is proportional to the idle time the loop can reuse. On a computation that keeps the processor busy, the program runs at exactly the same speed, with code that is harder to read.
Why do my tasks stop before they finish?
Because asyncio.run() closes the loop as soon as the main coroutine ends, without waiting for the ones still running. A task started with create_task is not a promise that it will reach the end: it has to be awaited by an await, or placed in a TaskGroup.
How is an error raised inside a task caught?
With a try around the await, and not around the line that starts the task: the error only surfaces when the result is claimed. On a gather, the return_exceptions=True option files errors among the results instead of tearing down the group. The Python course devotes a chapter to these waiting mechanics.