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

Analyzing Sequential Await Bottlenecks

Recommended resources

The freely available resources this lesson will direct you to.

Hello! Welcome back.

In our previous lesson, we established the foundation for asynchronous programming by creating coroutines with async def and simulating non-blocking I/O using await asyncio.sleep(). We ended by running two such coroutines sequentially and observed that the total execution time was simply the sum of their individual delays.

Today, we will dissect that observation. Our goal is to analyze the performance bottleneck of sequential await calls for simulated I/O-bound tasks. We will explore why our asynchronous code didn't run any faster than its synchronous equivalent and understand the precise execution flow that leads to this bottleneck.

This analysis is critical. In complex systems, like the multi-agent environments or distributed financial platforms you've worked with, performance hinges not just on the capability of individual components but on how their execution is orchestrated. The same is true in asyncio; merely defining coroutines is not enough. How we schedule and await them determines whether we achieve true concurrency or remain stuck in a sequential pattern.

Let's begin by re-examining the code from our last session.

1. The Bottleneck in Action

Recall the program we wrote, which simulated fetching two pieces of data with different delays.

import asyncio
import time

async def fetch_data(data_id: int, delay: float):
    """A coroutine that simulates a network request."""
    print(f"Starting to fetch data {data_id}...")
    await asyncio.sleep(delay)
    print(f"Finished fetching data {data_id}.")
    return {"data_id": data_id, "content": "some data"}

async def main():
    """The main entry point for our program."""
    start_time = time.perf_counter()
    
    print("--- Sequential Execution ---")
    await fetch_data(1, 2)
    await fetch_data(2, 3)
    
    end_time = time.perf_counter()
    print(f"Total time: {end_time - start_time:.2f} seconds")

if __name__ == "__main__":
    asyncio.run(main())

When you run this, the output clearly shows a sequential process, taking approximately 5 seconds:

--- Sequential Execution ---
Starting to fetch data 1...
Finished fetching data 1.
Starting to fetch data 2...
Finished fetching data 2.
Total time: 5.00 seconds

The program waits for fetch_data(1, 2) to complete entirely before it even starts fetch_data(2, 3). During the 2-second asyncio.sleep() in the first call, our program could have started the second fetch. But it didn't. Why?

2. The Mechanics of Sequential await

The answer lies in the dual role of the await keyword. It does two things:

  1. It pauses the execution of the current coroutine (e.g., main).
  2. It waits for the awaited coroutine (e.g., fetch_data) to complete before proceeding.

Let's trace the execution step-by-step from the event loop's perspective:

  1. asyncio.run(main()) starts the event loop and schedules the main coroutine to run.
  2. main begins executing and reaches await fetch_data(1, 2).
  3. The fetch_data(1, 2) coroutine starts. It prints its "Starting..." message and then hits await asyncio.sleep(2).
  4. At this point, fetch_data(1, 2) pauses and tells the event loop, "Wake me up in 2 seconds." It yields control.
  5. Control returns to where it was called from: the await expression in main. Because main is awaiting the completion of fetch_data(1, 2), main itself is now paused.
  6. The event loop looks for other tasks to run. There are none. The second call, fetch_data(2, 3), has not been made yet. The event loop is forced to idle for 2 seconds.
  7. After 2 seconds, the event loop wakes up fetch_data(1, 2). It runs to completion and returns its result.
  8. The completion of fetch_data(1, 2) un-pauses main. Execution in main finally moves to the next line.
  9. The entire process repeats for await fetch_data(2, 3).

The bottleneck is step 6. We have a non-blocking operation, but we haven't given the event loop any other work to do during the wait. The program structure forces a "stop-and-wait" behavior.

The following video provides a clear, practical demonstration of this exact issue.

Asyncio in Python - Full Tutorial

The video 'Asyncio in Python - Full Tutorial' by Tech With Tim explains this concept very well. Pay close attention to the explanation of why the total time is the sum of the individual waits.

Watch the segment from 9:04 to 10:25. The key takeaway is the phrase: 'a code routine doesn't start running until it's awaited'.

3. Coroutine Objects vs. Coroutine Execution

A crucial detail that reinforces the point from the video is the distinction between creating a coroutine object and executing it.

When you call a function defined with async def, it does not run the code inside it. Instead, it immediately returns a coroutine object.

Consider this example:

import asyncio

async def my_coroutine():
    print("Coroutine is now running.")
    return 42

# Create the coroutine object. Nothing is printed yet.
coro_obj = my_coroutine()

print(f"Type of coro_obj: {type(coro_obj)}")
print("The coroutine has not run yet.")

# To run it, we must await it within an async context.
async def main():
    print("About to await the coroutine object...")
    result = await coro_obj
    print(f"Coroutine has finished, result: {result}")

asyncio.run(main())

Output:

Type of coro_obj: <class 'coroutine'>
The coroutine has not run yet.
About to await the coroutine object...
Coroutine is now running.
Coroutine has finished, result: 42

This demonstrates that the coroutine object is just a blueprint for a future computation. It's the await keyword (or another scheduling function) that hands this object to the event loop for execution.

When we write await fetch_data(1, 2), we are performing two actions in one line:

  1. Creating the coroutine object: fetch_data(1, 2)
  2. Immediately awaiting it: await ...

This is why the second coroutine isn't created until the first has been awaited and has completed. We never give the event loop a chance to see both tasks at the same time.

To further illustrate this common scenario, let's watch another short clip.

AsyncIO, await, and async - Concurrency in Python

The video 'AsyncIO, await, and async' from Socratica uses a simple breakfast analogy to demonstrate the inefficiency of this sequential pattern.

Watch from 3:58 to 5:09. This clip shows how making coffee and toasting a bagel sequentially takes the sum of the times, highlighting the real-world inefficiency we are simulating in our code.

Conclusion

In this lesson, we've analyzed the performance bottleneck inherent in sequential await calls. While asyncio.sleep() makes our I/O-bound functions cooperative, our program's structure prevented any actual concurrency.

Key Takeaways:

  • Calling an async def function creates a coroutine object but does not execute it.
  • The await keyword submits the coroutine to the event loop for execution and pauses the current function until the awaited coroutine completes.
  • Awaiting coroutines in a simple sequence (await task1, await task2) creates a bottleneck because the second task is not started until the first one is finished.
  • The event loop remains idle during I/O waits in a sequential structure because it has not been given any other tasks to run.

We have now clearly identified the problem. The logical next step is to solve it.

Preview of the Next Lesson:

To overcome this bottleneck, we need a way to tell the event loop about multiple tasks before we start waiting for them. In the next lesson, we will learn how to use asyncio.create_task() to schedule coroutines for concurrent execution, finally unlocking the true power of asyncio.

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

Sign up