Topic 12.6
asyncio: async and await
In one line
asyncio runs many tasks on one thread. A function defined with async def is a coroutine that can pause at await, and the event loop (started by asyncio.run) runs whichever task is ready while the others wait.
Think of it like this
A waiter in a restaurant with ten tables. He takes the order at table 1, hands it to the kitchen, and does not stand there until the food is ready. He goes to table 2, then table 3, and comes back to table 1 when the kitchen rings the bell. One waiter, ten tables, no waiting around. The waiter is the event loop, each table is a task, and "hand the order to the kitchen and move on" is an **await**.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Coroutine
- A function defined with
async def. Calling it gives a coroutine object that has not run yet. - await
- Run this and wait for its result, letting other tasks run meanwhile.
- Event loop
- The loop that runs ready tasks one at a time and wakes waiting tasks when they can continue.
- Task
- A coroutine that has been scheduled on the event loop to run alongside others.
- asyncio.run
- Starts an event loop, runs one coroutine to the end, and shuts the loop down.
- create_task
- Schedules a coroutine to run as a task and returns the task right away.
- Blocking call
- A call that holds the thread until it finishes, such as
time.sleep. It freezes the event loop. - Suspension point
- A place where a coroutine can pause: every
await.
Step by step
01Calling is not running
greet("Ada") does not print anything. It builds a coroutine object. Only awaiting it runs the body. Look at the output: the type is printed and "nothing has printed yet" comes before "hello, Ada".
async def greet(name):
print("hello,", name)
return len(name)
coro = greet("Ada") # a coroutine object, not yet run
print(type(coro).__name__) # coroutine02await: sequential
Inside main, await coro runs it and hands back its return value. Two awaits in a row happen one after the other, just like normal function calls. asyncio.run(main()) is the single entry point that creates the loop. On this page, write it as its own top-level line.
03create_task: together
create_task hands a coroutine to the loop and returns immediately. In the output notice: "tasks created, neither has run yet" prints first, because the loop only gets control back at an await. At await asyncio.sleep(0) (a pause of zero length that just says "anyone else?") both tasks start. Task two finishes first because it sleeps less, then one.
04What the loop does, step by step
1. Run one ready task until it hits an await that cannot continue yet. 2. Put it aside with a note: "wake me when this is done". 3. Take the next ready task. 4. When a timer fires or an I/O result arrives, move the waiting task back to the ready list. 5. Repeat until main() returns.
Nothing runs in parallel. Between two awaits your code is alone on the thread. That is why one slow task can delay all the others.
05A blocking call freezes everything
In the next example a ticker prints tick 0, tick 1, tick 2 with short pauses. While a polite job awaits asyncio.sleep(0.1), the ticker prints all three ticks before the job ends. While a rude job calls time.sleep(0.1), nothing else can run: the job finishes first, and only then do ticks 1 and 2 appear. Compare the two orders carefully, as the order of lines is the proof.
06Forgetting await
If you write result = fetch() without await, result is a coroutine object, not the data. Python warns RuntimeWarning: coroutine 'fetch' was never awaited when the object is thrown away. Any time you see that warning, look for a missing await.
Try it yourself
- 1
Predict, then run
In the task example change the delays to
0.02and0.2. Which task ends first? Does the order of "begins" lines change? (No: both begin at the first hand-over, in the order the tasks were created.) - 2
Remove the hand-over
Delete
await asyncio.sleep(0)from the task example. The "begins" lines now appear only whenmainreachesawait t1. Explain why: a task cannot start until the loop gets control, and it gets control only at anawait. - 3
Make a blocking mistake
In the polite/rude example add a third job that does a long
forloop (for examplesum(range(5_000_000))) and see that it blocks the ticker too. A CPU loop is blocking even though it is not sleeping.
Code & diagrams
Look at the order: the type prints first, and the body of greet runs only inside main.
Expected output
type: coroutine
nothing has printed yet
hello, Ada
hello, Linus
lengths: 3 5create_task schedules the work; the loop starts it when main reaches an await.
Expected output
main starts
tasks created, neither has run yet
one begins
two begins
after one hand-over
two ends
one ends
results: ONE TWOCompare the two orders. In the rude run the job finishes before ticks 1 and 2, because nothing else could run during time.sleep.
Expected output
polite tick 0
polite tick 1
polite tick 2
polite job finished
rude tick 0
rude job finished
rude tick 1
rude tick 2Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forget await
Call a coroutine function without await and use its result.
import asyncio
async def fetch():
return "data"
async def main():
result = fetch() # forgot await
print("result is a", type(result).__name__)
asyncio.run(main())Myth vs fact
Myth
async functions run in parallel.
Fact
They run one at a time on one thread, and take turns at each await. The speed-up comes from overlapping waiting, not from parallel computation.
Myth
Adding async to a function makes it faster or non-blocking.
Fact
async def only makes it a coroutine. A blocking call inside it, such as time.sleep, still blocks everything.
Myth
await starts a new thread.
Fact
It does not. It suspends the current task and returns control to the event loop on the same thread.
Myth
You can call asyncio.run anywhere.
Fact
It is for the top of a program. Inside a running loop (a notebook, a web framework) use await instead.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
A coroutine is built on the same mechanism as a generator (Topic 9.2):
awaitis likeyield from. The coroutine's frame is kept in memory while it is suspended, and the loop resumes it withsend(). This is why a coroutine costs only a few kilobytes and tens of thousands of them are practical. - ▸
asyncio.runcreates a new loop and, on exit, cancels all remaining tasks and closes the loop. Tasks you create without keeping a reference can be garbage-collected (freed from memory, Topic 13.2) before they finish, since the loop only holds a weak reference. Always keep tasks in a variable, a set or aTaskGroup. - ▸
The loop uses the operating system's readiness API (epoll, kqueue, IOCP) to wait on many sockets at once. Real network I/O in asyncio uses non-blocking sockets; disk I/O is not truly async at the OS level in the standard library, so file work is usually pushed to a thread.
- ▸
Alternatives with the same
async/awaitsyntax exist (Trio, AnyIO), anduvloopis a faster drop-in loop for Linux and macOS. In this browser, the loop is a different implementation that works with the JavaScript event loop, but your code looks the same. - ▸
**Not every
awaitlets other tasks run.** Awaiting a coroutine that finishes without ever suspending (it just returns a value) hands nothing back to the loop: it runs inline, like a normal call. Control returns to the loop only when something underneath actually has to wait (a sleep, a socket, a future that is not done). A loop of a million such awaits therefore starves every other task.await asyncio.sleep(0)is the explicit way to yield once. - ▸
await coro()does not create a task, so there is no concurrency, no separate cancellation and no scheduling cost;create_taskdoes all three. Exceptions in a task are stored on it, and awaiting the task re-raises them, much like aFuture(Topic 12.4). Tasks keep their own copy of thecontextvarscontext, which is how web frameworks carry a request id throughawaits.
Remember this
- 1
A function defined with **
async defis a coroutine function. Calling it does not run the body: it returns a coroutine object**, a recipe that has not been started. Something must run it: eitherawaitinside another coroutine, orasyncio.run(...)at the top of your program, or wrapping it in a task. - 2
**
await** means: "run this and wait for its result, but while I wait, let other tasks run". It can be used only inside anasync def. You canawaitcoroutines, tasks and futures (a future is a placeholder for a result that is not ready yet, Topic 12.4). Anawaitis the only place where a task can be paused, which is why asyncio is called cooperative (Topic 12.1). - 3
The event loop is a loop that keeps a list of tasks that are ready and runs one at a time until it reaches an
awaitthat must wait; then it picks the next ready task. When a sleep ends or a reply arrives, the waiting task becomes ready again.asyncio.run(main())creates a loop, runsmain()to the end, then closes the loop. Call it once, at the top of the program. - 4
Awaiting a coroutine directly runs it to completion before moving on:
await a(); await b()is sequential. To run several at once, wrap each in a task withasyncio.create_task(coro). A task starts running as soon as you give the loop a chance (at the nextawait), and you collect its result later by awaiting the task. The next topic showsgatherandTaskGroup, which do this for you. - 5
asyncio.sleep(n)pauses only this task fornseconds and lets others run; it is the stand-in for any real waiting (a network call).time.sleep(n)pauses the whole thread, so every task freezes. This is the most important rule in asyncio: never call blocking code inside a coroutine. Blocking code includestime.sleep,requests.get, heavy loops, and many old SDK calls. - 6
The classic beginner mistakes are forgetting
await(you get a coroutine object, and aRuntimeWarning: coroutine ... was never awaited), callingasyncio.runfrom inside a running loop (as in Jupyter, an interactive notebook where a loop is already running, so you useawaitdirectly), and blocking the loop. - 7
In the AI world,
asynciois how one program talks to many model endpoints, vector databases and tools at the same time. Async clients such ashttpx.AsyncClientand the async mode of LLM SDKs (the SDK is the library a provider gives you for its API) are built on exactly this.
Explain it without notes
What is the difference between a coroutine function and a coroutine object?
What does the event loop do, and what makes a task give up control?
Why does time.sleep inside a coroutine cause problems but asyncio.sleep does not?
What is the difference between await coro() and asyncio.create_task(coro())?
Does every await let other tasks run? What happens to the rest of the program if a coroutine awaits a million quick coroutines in a loop?
Practice
Write a coroutine double(n) that sleeps 0.01 seconds and returns n * 2. Await it for 5 and print the result.
Start two tasks slow (sleeps 0.05, prints slow) and fast (sleeps 0.01, prints fast) and wait for both. What is printed?
This code prints nothing useful: async def get(): return 5 then inside main: value = get() and print(value + 1). Fix it.
Trade-offs
- ↔
asyncio vs threads: much cheaper per task and no random thread switches, but every library in the path must cooperate (use
await), and the whole program tends to become async ("function colouring"). - ↔
Many small tasks vs one big one: small tasks overlap better, but each switch has a cost; tasks that run long without an
awaithurt everyone. - ↔
Cooperative scheduling: no races between awaits, but fairness is your job: a task must
awaitregularly.
Done when you can
Done when you can explain the waiter analogy for the event loop and
await.Done when you can say what calling an
async deffunction returns and how to run it.Done when you can create tasks and predict the order of printed lines.
Done when you can say why
time.sleepis wrong inside a coroutine and what to use instead.Done when you can spot a missing
awaitfrom thenever awaitedwarning.