Async/Await in Python

Last week, we dove into parallelism and CPU-heavy workloads, but most day-to-day Python is about plumbing systems together and implementing business logic. Today, I want to walk through async/await and how it’s implemented in CPython.

I’m personally rooting for wider adoption of the async/await syntax - mainly because it preserves a linear code flow, making programs more readable and allowing developers to focus on the actual problem instead of managing callback hell. It should be your default choice for workloads that are mostly IO-bound - which, realistically, covers the bulk of what we do: backends, APIs, and similar systems.

Let’s start with how async/await achieves concurrency. I grew up working with microcontrollers, which don’t have an OS and require manual concurrency handling:

def main():
    while True:
        ...
        if something_needs_to_be_done(): # someone pressed a button
            start_task() # create a message packet and send it, start receiving
        ...
        if is_task_done(): # check if the receive buffer has new data in it
            handle_result_of_task() # read the data and do something with it

This is a classic pattern you’ll find in older codebases or resource-constrained systems: a single, fast-running loop that constantly checks flags and state to react accordingly. Everything runs on one thread, usually with a mess of global variables. It’s ugly and hard to maintain, but it doesn’t waste cycles and gives you full control. And importantly - it achieves concurrency without threads or processes.

So what does this have to do with async/await? Well, this is essentially what it does under the hood. The await keyword simply tells Python: “If we’re waiting on something, go ahead and run other stuff in the meantime.”

Python’s asyncio library provides the AbstractEventLoop, whose run_forever() function mirrors the primitive loop from above. A specialized subclass of this loop (the “executor”) is used when you call asyncio.run() to start your async program. Internally, this implementation is more complex and relies heavily on function callbacks.

There are multiple event loop implementations, picked automatically based on the OS, but they all follow the same basic principle. On each iteration, the loop:

  1. Removes cancelled callbacks
  2. Adds new events from selectors (mostly IO operations) and marks them as ready
  3. Checks scheduled callbacks and moves any due ones to the ready queue
  4. Executes all callbacks that are ready So, Python spends most of its time executing your code and with each await keyword it briefly visits the event loop code to decide what to do next.

That means we could rewrite the earlier microcontroller example as:

async def task():
    start_task() # create a message packet and send it, start receiving
    result = await is_task_done(): # wait for the new data without blocking main
    handle_result_of_task(result) # read the data and do something with it
    

async def main():
    while True:
        ...
        if something_needs_to_be_done(): # someone pressed a button
            asyncio.create_task(task)
        ...

Even though we’re using terms like “task” and “schedule” no threads or processes are involved - this could still run on a resource-constrained system. But now it’s far easier to read and reason about. Note: I am talking about microcontroller code, but you can substitute that with “performance critical code” in server contexts.

A crucial thing to remember when using async/await - everything runs in the same thread. This is cooperative multitasking, meaning your code has to voluntarily yield control. You’ll likely use an async-compatible HTTP client like httpx.AsyncClient, but if you accidentally block the event loop - say, by using with open() to save a large file - you’ll freeze the entire program during that operation.

Other async frameworks exist beyond asyncio, including Trio and Twisted. They all follow the same core principles, with their own trade-offs and design philosophies.


Last updated on April 15, 2025