Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Asyncio Fundamentals

Hello! Welcome to your next lesson in the "Production-Quality Python" module.

In our last session, we implemented the producer-consumer pattern using threading and threading.Condition. We established that this is a powerful pattern for coordinating I/O-bound work by decoupling the threads producing the work from those consuming it.

However, thread-based concurrency in Python, while effective, comes with the overhead of managing OS-level threads and navigating the complexities of the Global Interpreter Lock (GIL). Today, we pivot to a different, more modern approach to concurrency in Python: asyncio.

Your background with Rust's Tokio gives you a significant head start here. You're already familiar with the async/await syntax and the concept of a runtime that schedules tasks. The learning outcome for this lesson is to write asynchronous code using asyncio coroutines, tasks, and event loops. We will map the concepts you know from Tokio—like tasks, schedulers, and non-blocking I/O—to their Python counterparts, while also highlighting the key differences, most notably asyncio's single-threaded nature.

1. The asyncio Paradigm: Cooperative Multitasking

Before we dive into code, it's crucial to understand why and when to use asyncio. Like threading, it's designed for I/O-bound workloads—situations where your program spends most of its time waiting for network requests, database queries, or file operations.

Unlike threading, which uses pre-emptive multitasking managed by the OS, asyncio uses cooperative multitasking on a single thread. This means functions explicitly yield control back to a central coordinator, the event loop, allowing another function to run.

Python Async/Await Playbook: Decide whether asyncio is the right tool

To frame our discussion, this article provides a superb high-level overview of the decision-making process for adopting asyncio. It correctly identifies the ideal use cases and warns against common misapplications.

Please read the first section, "1. Decide whether asyncio is the right tool (most teams get this wrong)". Focus on the description of I/O-bound, high-latency, high fan-out tasks as the sweet spot for asyncio.

The core benefit is managing thousands of concurrent operations with much lower memory overhead than an equivalent number of threads. Because it's single-threaded, it also sidesteps many of the complex synchronization issues (like race conditions requiring locks) inherent in multi-threaded programming.

2. The Core Components of asyncio

asyncio's architecture revolves around three fundamental concepts: coroutines, the event loop, and tasks.

  • Coroutine (async def): A function defined with async def. When you call a coroutine function, it doesn't execute immediately. Instead, it returns a coroutine object. This is conceptually similar to how an async function in Rust returns a Future—it's a representation of a computation that can be run later.
  • Event Loop: The heart of asyncio. It's the scheduler that runs asynchronous tasks and callbacks, performs network I/O, and manages subprocesses. You can think of it as the equivalent of the Tokio runtime, but running on a single thread.
  • await: A keyword used inside a coroutine to pause its execution and pass control back to the event loop until the awaited operation (e.g., an I/O call) is complete.

The following diagram illustrates how these components interact.

Python Asyncio EventLoop Sequence Diagram
This sequence diagram shows the flow of control in an `asyncio` application. The main thread starts the event loop, which runs a coroutine. When the coroutine `await`s an I/O operation, control returns to the event loop, which registers the operation with the OS and waits. Once the OS signals completion, the event loop resumes the coroutine.

3. From Coroutine to Concurrent Task: The Critical Distinction

This is the most common point of confusion for newcomers to asyncio, and where your Tokio experience is most relevant.

Simply awaiting a coroutine runs it, but it does not create concurrency. The parent coroutine pauses and waits for the child coroutine to complete before continuing. It's still sequential execution.

import asyncio

async def say_after(delay, what):
    await asyncio.sleep(delay)
    print(what)

async def main():
    print("Execution started")
    await say_after(1, "hello") # main() pauses here for 1 second
    await say_after(2, "world") # then main() pauses here for 2 seconds
    print("Execution finished")




# Total runtime will be ~3 seconds
asyncio.run(main())

To achieve true concurrency, you must schedule the coroutine to run on the event loop independently. This is done by wrapping it in a Task.

A Task is an object that manages the execution of a coroutine. Creating a task with asyncio.create_task() immediately schedules it to run on the event loop "in the background". This is the direct Python equivalent of tokio::spawn.

import asyncio

async def say_after(delay, what):
    await asyncio.sleep(delay)
    print(what)

async def main():
    print("Execution started")



    # Schedule both coroutines to run as independent tasks
    task1 = asyncio.create_task(say_after(1, "hello"))
    task2 = asyncio.create_task(say_after(2, "world"))




    # Now, wait for both tasks to complete
    await task1
    await task2
    print("Execution finished")




# Total runtime will be ~2 seconds (the duration of the longest task)
asyncio.run(main())

This distinction is fundamental. Awaiting a coroutine is like calling a synchronous function; creating a task is like spawning a thread (or a green thread in this case).

Await Is Not a Context Switch: Understanding Python's Coroutines vs. Tasks

The web is full of confusing explanations on this topic. This Hacker News comment thread provides one of the clearest, most practical illustrations of why simply awaiting a coroutine doesn't yield control to other tasks.

Read the initial comment by 'scribu' which provides a corrected code example. Then, read the 'horror story' from 'reactordev' about a system that failed to be concurrent. Finally, review the comment by 'contravariant' which shows that a coroutine does nothing until it's awaited.

4. Visualizing the Event Loop in Action

Reading about the event loop is one thing; seeing it work is another. The following video provides an excellent, animated guide that walks through the concepts we've just discussed.

Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations

This comprehensive guide by Corey Schafer is one of the best asyncio tutorials available. We'll watch several key sections. The animations clearly demonstrate the difference between sequential execution with await and concurrent execution with create_task.

Please watch the video in segments, focusing on these parts: Introduction & Terminology (00:00 - 13:53): This covers the key terms: coroutine, task, future, and event loop. Pay close attention to the definitions. Sequential vs. Concurrent Execution (13:53 - 31:26): This is the most important part. Watch the animations that compare a synchronous script, an incorrectly concurrent script (sequential await), and a correctly concurrent script using asyncio.create_task. This will solidify the concepts from the previous section. Managing Multiple Tasks (54:51 - 01:07:17): This section introduces asyncio.gather and asyncio.TaskGroup, which are practical tools for managing collections of tasks, similar to join_all in Rust's futures crate.

A critical rule highlighted in the video and other resources is: Don't Block the Event Loop.

Any standard, synchronous function call that takes time (like time.sleep(), CPU-intensive work, or using the requests library) will freeze the entire asyncio application. All other tasks will starve until the blocking call completes.

Python Async/Await Playbook: Keep the event loop non-blocking

This article reinforces the dangers of blocking the event loop from a production perspective.

Read the section "2. Keep the event loop non-blocking (or watch everything freeze)". It lists common blocking operations and the correct async alternatives.

Conclusion

In this lesson, we transitioned from thread-based concurrency to the single-threaded, cooperative model of asyncio. Drawing parallels to your experience with Tokio, we've seen that while the syntax is familiar, the underlying execution model is quite different.

Key Takeaways:

  • asyncio excels at I/O-bound concurrency on a single thread, using an event loop for cooperative multitasking.
  • Calling an async def function creates a coroutine object but does not run it.
  • await pauses a coroutine and yields control to the event loop. However, await some_coroutine() is a sequential operation.
  • True concurrency is achieved with asyncio.create_task(), which schedules a coroutine to run on the event loop independently. This is the key to running multiple operations at once.
  • Blocking the event loop with synchronous code is the most critical mistake, as it freezes the entire application.

Next Lesson Preview

Now that you can create and run concurrent tasks, the next logical step is to control them. What if you want to make 500 network requests, but only 10 at a time to avoid overwhelming a server? In our next lesson, you will learn how to manage concurrency levels in asyncio applications using semaphores, a fundamental tool for rate limiting and resource management.

Can't find a good explanation? Sign up and we'll make it for you

Sign up