Definition
A script that downloads thirty web pages spends almost all of its time doing nothing: it sends a request, then waits for the server to answer while the processor sits idle. Thirty times over, the program is slow without having computed anything.
async is the keyword that lets you claim that wasted time back. Placed in front of def, it turns an ordinary function into a coroutine function: a function able to pause and step aside for another one while it waits.
The first consequence surprises everyone: calling a normal function runs it, calling an async function does not.
async def load(url):
print("Downloading", url)
return url.upper()
load("report.csv")
# <coroutine object load at 0x104e2f740>
# Nothing was printedThe call handed back a coroutine object: a piece of work described and ready to go, that nobody has started yet. It has to be given to a runner first.
Describing a job and starting it thus become two separate gestures: prepare fifty of them before launching a single one, then move them forward together.
The async, await and asyncio trio
A lot of asynchronous code that runs no faster comes from the same confusion: believing the keyword is enough. Three pieces in fact share the work.
| Piece | Role |
|---|---|
async | Declares that the function is allowed to wait |
await | Marks the waiting point and hands control back meanwhile |
asyncio | Provides the loop that moves every task forward |
The keyword on its own therefore makes nothing concurrent. It opens a door: inside a function declared this way, writing await becomes legal, and it is that keyword which switches from one task to another. The loop still has to be started, the job of asyncio.run.
The three pieces together give this, where asyncio.gather starts both downloads at once.
import asyncio
async def main():
reports = await asyncio.gather(
load("january.csv"),
load("february.csv"),
)
return reports
asyncio.run(main())In a Jupyter notebook a loop is already running in the background, and asyncio.run answers with "asyncio.run() cannot be called from a running event loop". Write await main() in the cell instead.
What it speeds up, and what it does not
Will the program run faster for all that? It depends on where it spends its time, since the gain comes from waiting, never from computing. An async function does not run faster: it steps aside for the others while it waits.
The diagram compares the two regimes on three network calls of one second each.
Which leaves the question of what counts as waiting. Here are the four cases met in practice.
| Kind of work | Real effect |
|---|---|
| HTTP requests, API calls | Huge gain, a hundred waits fit in the time of one |
| Database access | Clear gain, provided the library was built for it |
| Heavy computing, image processing | No gain, the processor stays busy and nobody yields |
File reading with open | No gain, the call freezes the whole loop |
The last two rows are worth a pause. A single blocking instruction cancels the benefit of everything else: the loop is unique, and while it is frozen, no task moves forward.
Calling time.sleep(2) or requests.get inside an async function freezes the whole program for the length of the operation. Only the asynchronous counterparts, such as asyncio.sleep, hand control back.
Pure computing calls for separate processes. threading sits in between, with threads handed to the system. The demonstration below takes the comparison up again.
The calls spend most of their time waiting for a reply. The waits overlap and total duration approaches that of a single call: this is the ground where async changes everything.
The model assumes waits overlap perfectly and computation is serialised, which is how asyncio behaves on a single thread. The global interpreter lock is why computation, unlike waiting, gains nothing from async.
The two statements it changes
Inside an asynchronous function, two familiar statements raise the same problem: opening a connection takes time, and reading a stream row by row too. Python gives them a version that knows how to wait.
async def process(session):
async with session.connect() as conn:
async for row in conn.stream("SELECT * FROM sales"):
await store(row)async with waits for the opening and then the closing of a context manager, where with runs both in one go. A connection can thus be established without holding up the rest of the program.
async for waits for each item of a source that produces them over time, where a plain for loop demands the next one immediately. The principle covers asynchronous generators, where yield lives alongside waiting, and these forms only exist inside a function declared async.
The forgotten call trap
That leaves the mistake that costs the most time, all the more devious for raising no exception at all. The program settles for a quiet warning, often drowned in the output.
async def main():
load("january.csv") # await forgotten: nothing starts
await load("february.csv") # correct
# RuntimeWarning: coroutine 'load' was never awaitedThe first line created a coroutine then dropped it. The program ends normally, the file is never loaded, and the expected value shows up as None a few lines later, far from its cause.
Hence the habit: any call to an async function that is neither preceded by await nor handed to a task is a bug.
Frequently asked questions
Can an async function be called from a normal function?
Not directly: the call would hand back an inert object. It has to go through asyncio.run, which starts the loop and acts as the entry point, or already sit inside asynchronous code. Hence the single asyncio.run, right at the top of a project.
Should every function be declared async?
No, and the habit is an expensive one. The keyword spreads upwards: a function that waits must in turn be awaited, and one is enough to contaminate the whole call chain. Keep it for the functions that perform input and output; the business logic stays in ordinary functions, easier to test.
Does async replace threads?
For network waiting, yes: it uses far less memory and avoids locks. For computing, no, and no more so against a library that only knows the blocking mode. That boundary is worked on real cases in the Python course.