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:
- It pauses the execution of the current coroutine (e.g.,
main). - 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:
asyncio.run(main())starts the event loop and schedules themaincoroutine to run.mainbegins executing and reachesawait fetch_data(1, 2).- The
fetch_data(1, 2)coroutine starts. It prints its "Starting..." message and then hitsawait asyncio.sleep(2). - At this point,
fetch_data(1, 2)pauses and tells the event loop, "Wake me up in 2 seconds." It yields control. - Control returns to where it was called from: the
awaitexpression inmain. Becausemainisawaiting the completion offetch_data(1, 2),mainitself is now paused. - 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. - After 2 seconds, the event loop wakes up
fetch_data(1, 2). It runs to completion and returns its result. - The completion of
fetch_data(1, 2)un-pausesmain. Execution inmainfinally moves to the next line. - 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:
- Creating the coroutine object:
fetch_data(1, 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 deffunction creates a coroutine object but does not execute it. - The
awaitkeyword 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.