Command Palette

Search for a command to run...

Hectal
PHASE 12Advanced ~27 min· topic 6 of 9

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".

main.pywhole filepython
async def greet(name):
    print("hello,", name)
    return len(name)

coro = greet("Ada")            # a coroutine object, not yet run
print(type(coro).__name__)     # coroutine

02await: 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.

create_task: togetherdiagram
Rendering diagram…

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.

What the loop does, step by stepdiagram
Rendering diagram…

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. 1

    Predict, then run

    In the task example change the delays to 0.02 and 0.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. 2

    Remove the hand-over

    Delete await asyncio.sleep(0) from the task example. The "begins" lines now appear only when main reaches await t1. Explain why: a task cannot start until the loop gets control, and it gets control only at an await.

  3. 3

    Make a blocking mistake

    In the polite/rude example add a third job that does a long for loop (for example sum(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

A coroutine is a recipe until it is awaited New tab

Look at the order: the type prints first, and the body of greet runs only inside main.

Sign in to run this example in your browser.

Expected output

type: coroutine
nothing has printed yet
hello, Ada
hello, Linus
lengths: 3 5
Two tasks running together New tab

create_task schedules the work; the loop starts it when main reaches an await.

Sign in to run this example in your browser.

Expected output

main starts
tasks created, neither has run yet
one begins
two begins
after one hand-over
two ends
one ends
results: ONE TWO
Polite vs rude: blocking freezes the loop New tab

Compare the two orders. In the rude run the job finishes before ticks 1 and 2, because nothing else could run during time.sleep.

Sign in to run this example in your browser.

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 2

Break 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.

main.pywhole filepython
import asyncio


async def fetch():
    return "data"


async def main():
    result = fetch()                  # forgot await
    print("result is a", type(result).__name__)


asyncio.run(main())
terminal
$ python main.py
── what you'll see ──
result is a coroutine
main.py:13: RuntimeWarning: coroutine 'fetch' was never awaited
asyncio.run(main())
RuntimeWarning: Enable tracemalloc to get the object allocation traceback

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): await is like yield from. The coroutine's frame is kept in memory while it is suspended, and the loop resumes it with send(). This is why a coroutine costs only a few kilobytes and tens of thousands of them are practical.

  • ▸

    asyncio.run creates 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 a TaskGroup.

  • ▸

    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/await syntax exist (Trio, AnyIO), and uvloop is 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 await lets 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_task does all three. Exceptions in a task are stored on it, and awaiting the task re-raises them, much like a Future (Topic 12.4). Tasks keep their own copy of the contextvars context, which is how web frameworks carry a request id through awaits.

Remember this

  1. 1

    A function defined with **async def is 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: either await inside another coroutine, or asyncio.run(...) at the top of your program, or wrapping it in a task.

  2. 2

    **await** means: "run this and wait for its result, but while I wait, let other tasks run". It can be used only inside an async def. You can await coroutines, tasks and futures (a future is a placeholder for a result that is not ready yet, Topic 12.4). An await is the only place where a task can be paused, which is why asyncio is called cooperative (Topic 12.1).

  3. 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 await that 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, runs main() to the end, then closes the loop. Call it once, at the top of the program.

  4. 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 with asyncio.create_task(coro). A task starts running as soon as you give the loop a chance (at the next await), and you collect its result later by awaiting the task. The next topic shows gather and TaskGroup, which do this for you.

  5. 5

    asyncio.sleep(n) pauses only this task for n seconds 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 includes time.sleep, requests.get, heavy loops, and many old SDK calls.

  6. 6

    The classic beginner mistakes are forgetting await (you get a coroutine object, and a RuntimeWarning: coroutine ... was never awaited), calling asyncio.run from inside a running loop (as in Jupyter, an interactive notebook where a loop is already running, so you use await directly), and blocking the loop.

  7. 7

    In the AI world, asyncio is how one program talks to many model endpoints, vector databases and tools at the same time. Async clients such as httpx.AsyncClient and 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

01

What is the difference between a coroutine function and a coroutine object?

02

What does the event loop do, and what makes a task give up control?

03

Why does time.sleep inside a coroutine cause problems but asyncio.sleep does not?

04

What is the difference between await coro() and asyncio.create_task(coro())?

05

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

01

Write a coroutine double(n) that sleeps 0.01 seconds and returns n * 2. Await it for 5 and print the result.

02

Start two tasks slow (sleeps 0.05, prints slow) and fast (sleeps 0.01, prints fast) and wait for both. What is printed?

03

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 await hurt everyone.

  • ↔

    Cooperative scheduling: no races between awaits, but fairness is your job: a task must await regularly.

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 def function 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.sleep is wrong inside a coroutine and what to use instead.

  • Done when you can spot a missing await from the never awaited warning.