Home Tutorials Concurrency

Concurrency

Asyncio and async/await: Python's Concurrency Model Explained

Pyford Notes July 6, 2026 7 min read
Key points
  • async def defines a coroutine function; calling it returns a coroutine object rather than running the body immediately.
  • await hands control back to the event loop while a coroutine is waiting, letting other coroutines run on the same single thread in the meantime.
  • asyncio.gather() runs several coroutines concurrently and returns their results once all of them finish.
  • A single blocking call, like a synchronous requests.get(), freezes the entire event loop for every task, not just the one that made it.

One thread, many waiting tasks

Threading and multiprocessing both hand work to the operating system to schedule. Asyncio takes a different approach entirely: a single thread runs an event loop that switches between many tasks itself, cooperatively, whenever a task explicitly says it's waiting. Nothing runs in parallel in the sense of two CPU instructions executing at the same instant — instead, while one task is waiting on a network response, the loop runs another task that's ready to make progress, and switches back once the first one's data arrives.

This works well for the same category of problem threading suits — lots of I/O waiting — but scales to far more concurrent tasks with far less overhead, because there's no operating-system thread being created and scheduled for each one.

Coroutines: what async def and await actually do

A function defined with async def is a coroutine function. Calling it doesn't run the body — it returns a coroutine object that has to be awaited or scheduled before anything inside it executes:

import asyncio

async def greet(name, delay):
    await asyncio.sleep(delay)
    print(f"hello, {name}")

async def main():
    await greet("Ada", 1)

asyncio.run(main())

asyncio.sleep() is the async equivalent of time.sleep(), but instead of blocking the thread, it tells the event loop "wake this coroutine up again in one second, and run something else in the meantime." await is the point where a coroutine can be paused and the loop is free to switch away — it only appears in front of things that are themselves awaitable, such as another coroutine or an asyncio primitive.

Running coroutines concurrently with gather

Awaiting coroutines one after another runs them sequentially — no different from calling ordinary functions in a row. asyncio.gather() schedules several coroutines to run concurrently and waits for all of them:

import asyncio
import time

async def fetch(id, delay):
    await asyncio.sleep(delay)
    return f"result-{id}"

async def main():
    start = time.perf_counter()
    results = await asyncio.gather(
        fetch(1, 1),
        fetch(2, 1),
        fetch(3, 1),
    )
    print(results, time.perf_counter() - start)  # ~1 second, not ~3

asyncio.run(main())

All three fetch calls sleep for a second, but because they run concurrently on the event loop, the whole batch finishes in about a second rather than three. gather() returns results in the same order the coroutines were passed in, regardless of which one actually finishes first.

The blocking-call trap

Asyncio's concurrency only works if every coroutine actually yields control at its await points. A synchronous, blocking call inside a coroutine — a plain time.sleep(), a synchronous HTTP library, a heavy CPU loop — ties up the single thread the event loop runs on, and nothing else can make progress until it returns:

async def bad():
    time.sleep(2)   # blocks the whole event loop, not just this task

async def good():
    await asyncio.sleep(2)   # yields control back to the loop

This is the most common source of "my asyncio code isn't actually concurrent" bugs. The fix is to use async-aware libraries throughout — aiohttp instead of a synchronous HTTP client, asyncpg instead of a blocking database driver — or, when a blocking call genuinely can't be avoided, run it in a thread pool with asyncio.to_thread() so it doesn't hold up the loop. The event loop's full set of scheduling and task-management functions is listed in the official asyncio documentation.

asyncio vs. threading vs. multiprocessing

All three solve overlapping problems but suit different shapes of work. Asyncio scales best when you need thousands of concurrent, mostly-waiting connections, such as a server handling many simultaneous clients, and every I/O library involved has an async-native version. Threading fits when you're working with existing synchronous libraries and only need dozens to hundreds of concurrent I/O operations, since ordinary blocking calls work fine inside a thread. Multiprocessing is the only one of the three that gets real parallel CPU execution, and it's the right tool once the bottleneck is computation rather than waiting. None of them is a universal replacement for the others; the choice follows from what the workload is actually waiting on, or not waiting on at all.

The three also mix less cleanly than they might first appear. Calling ordinary blocking, synchronous code from inside a coroutine defeats the point of asyncio unless it's routed through to_thread() or a similar bridge, and spawning a whole new process from within an async application to handle CPU-bound work needs its own coordination through loop.run_in_executor(). A server built on asyncio that also needs to run a heavy computation on each request is a common case where all three end up combined: the event loop handles thousands of concurrent connections, while CPU-heavy requests get handed off to a process pool so they don't stall everyone else's response. Reaching for that combination from the start, before profiling shows it's actually needed, usually adds more complexity than the workload justifies.