Skip to content
← All Python articles

Python explainer

asyncio from Zero: Coroutines, await and the Event Loop

About 4 min to read. Allow extra time to work through examples.

Learn what async def, await and asyncio.run do, why awaiting one call at a time is still sequential, and why a blocking call freezes every task.

This article uses Python. You can make it your practice mode to browse related content across the site.

Threads let your program wait for several things at once, but each thread costs memory, and the operating system decides when to switch between them. asyncio runs many tasks in a single thread, and switches between them only at the points you mark with await.

asyncio is a good fit when a program waits on many things at once, such as hundreds of HTTP requests or a chat server with thousands of open connections.

Coroutines and await

Put async in front of def and you get a coroutine function:

import asyncio


async def fetch(url: str) -> str:
    await asyncio.sleep(0.5)  # stands in for a network call
    return f"<html>{url}</html>"

Calling fetch("a") does not run it. It returns a coroutine, an object that runs when something awaits it. await runs a coroutine and pauses the current one until that coroutine finishes. Only code inside an async def function can use await.

asyncio.run() starts the event loop, the scheduler that runs coroutines, and runs one coroutine until it finishes:

async def main() -> None:
    page = await fetch("a")
    print(page)


asyncio.run(main())

Call asyncio.run() once, at the top of your program. Everything below it is async def functions awaiting each other.

Awaiting one call at a time is still sequential

This version downloads three pages, but it is no faster than ordinary code:

async def main() -> None:
    a = await fetch("a")
    b = await fetch("b")  # starts only after "a" is done
    c = await fetch("c")

Each await waits for its download before the next line runs. To run the three at the same time, pass them all to asyncio.gather:

async def main() -> None:
    a, b, c = await asyncio.gather(fetch("a"), fetch("b"), fetch("c"))

gather starts every coroutine you give it, waits for all of them, and returns their results in the order you passed them. The three downloads overlap, so the whole thing takes about 0.5 seconds instead of 1.5.

How the switching works

When a coroutine awaits something that is not ready, such as a network reply or asyncio.sleep, it pauses and hands control back to the event loop. The loop then runs another coroutine that is ready to continue. When the reply arrives, the loop resumes the paused coroutine where it left off.

So asyncio switches only at an await. Between two awaits, no other coroutine runs, so your code is never interrupted there. A value you read before an await can still change by the time the code after it runs.

Never block the event loop

Everything runs in one thread. If a coroutine calls something that blocks, such as time.sleep(1) or a synchronous HTTP library, nothing else runs until that call returns.

async def bad() -> None:
    time.sleep(1)  # blocks the whole program for 1 second


async def good() -> None:
    await asyncio.sleep(1)  # pauses only this coroutine

Inside asyncio code, use async libraries for I/O, such as httpx.AsyncClient or aiohttp for HTTP. When you have to call blocking code, await asyncio.to_thread(blocking_function, arg) runs it on a worker thread so the loop can keep running other coroutines. A later exercise covers this.

Tasks run in the background

asyncio.create_task(coro) schedules a coroutine on the event loop and returns a task right away, without waiting for it. Await the task later to get its result:

async def main() -> None:
    task = asyncio.create_task(fetch("a"))
    ...  # do other work while "a" downloads
    page = await task

Keep a reference to every task you create, and await it at some point. Later items in this track explain why, and introduce asyncio.TaskGroup, which waits for every task it starts.

Check your understanding Inside an async def function, you call fetch("a") but forget the await. What happens? Show answer

The download never runs. Calling a coroutine function only creates a coroutine object. When Python discards a coroutine that nothing awaited, it warns "coroutine 'fetch' was never awaited". Add await, or pass the coroutine to asyncio.gather or asyncio.create_task.

step 16 of 56

Concurrency with Threads and asyncio

Up next Download Pages at the Same Time with asyncio