Hello! Welcome back to our course on FastAPI.
In the last lesson, we contrasted the high-level programming models of Django and FastAPI. We established that FastAPI's performance comes from its async-first nature, allowing a single worker to handle many tasks concurrently, much like a cook juggling multiple orders at once. We talked about what async and await do conceptually.
Today, we're moving from "what" to "how." This lesson is about getting your hands dirty and writing the kind of asynchronous code that powers FastAPI.
Our learning outcome is to: Write Python coroutines using async and await and run them concurrently using asyncio.gather.
We'll break this down into three parts:
- Understanding the building blocks: coroutine functions and coroutine objects.
- Recognizing a common pitfall: writing asynchronous code that runs sequentially.
- Using
asyncio.gatherto achieve true concurrency.
This is the core mechanic that unlocks the performance benefits we discussed previously, and mastering it is a fundamental step in your transition from the synchronous world of Django.
From Functions to Coroutines
In standard Python, a function call executes code immediately. In the asyncio world, things are a bit different. Let's start with a familiar synchronous function.
import time
def synchronous_task(task_id: int, delay: int):
print(f"Sync task {task_id}: starting, will sleep for {delay}s.")
time.sleep(delay) # This is a blocking call
print(f"Sync task {task_id}: finished.")
return f"Result of task {task_id}"
To turn this into an asynchronous equivalent, we use async def and replace any blocking I/O calls (like time.sleep) with their non-blocking counterparts (like asyncio.sleep).
import asyncio
async def asynchronous_task(task_id: int, delay: int):
print(f"Async task {task_id}: starting, will sleep for {delay}s.")
await asyncio.sleep(delay) # This is a non-blocking call
print(f"Async task {task_id}: finished.")
return f"Result of task {task_id}"
Here's the most critical distinction:
- Calling
synchronous_task(1, 2)runs the function and returns its result after 2 seconds. - Calling
asynchronous_task(1, 2)does not run the function. Instead, it immediately returns a coroutine object.
Think of a coroutine object as a blueprint or a recipe for a task. It describes what needs to be done, but nothing happens until you explicitly tell the event loop to execute it using the await keyword.
To run a top-level coroutine, we use asyncio.run(), which starts the event loop, runs our coroutine until it's complete, and then closes the loop.
async def main():
print("Starting the main coroutine.")
# Here, we 'await' the coroutine object to execute it
result = await asynchronous_task(1, 2)
print(f"Received: {result}")
print("Main coroutine finished.")
if __name__ == "__main__":
asyncio.run(main())
Running this script will produce output showing that the asynchronous_task is executed only when await is called.
For a detailed walkthrough of this terminology—event loop, async def, await, coroutine functions, and coroutine objects—the following video is an excellent guide.
Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations
Corey Schafer's guide to AsyncIO provides an in-depth look at the fundamentals. This segment clearly explains the key terms and the difference between defining a coroutine function and getting a coroutine object when you call it.
Watch from 03:11 to 12:13. Focus on the distinction between a coroutine function (what you define with async def) and a coroutine object (what you get when you call it). Also, note the role of await and asyncio.run.
The Pitfall: Sequential await
Now that we can run a single coroutine, let's try running two. A common first attempt looks like this:
import asyncio
import time
# (asynchronous_task definition from above)
async def main_sequential():
start_time = time.time()
print("Running tasks sequentially...")
result1 = await asynchronous_task(1, 2)
result2 = await asynchronous_task(2, 3)
end_time = time.time()
print(f"Main sequential finished in {end_time - start_time:.2f} seconds.")
print(f"Results: {[result1, result2]}")
if __name__ == "__main__":
asyncio.run(main_sequential())
If you run this, you'll see an output like this:
Running tasks sequentially...
Async task 1: starting, will sleep for 2s.
Async task 1: finished.
Async task 2: starting, will sleep for 3s.
Async task 2: finished.
Main sequential finished in 5.01 seconds.
Results: ['Result of task 1', 'Result of task 2']
Notice the total time is ~5 seconds (2 + 3). Even though we're using async/await, the code behaves synchronously. We await the first task to its completion before even starting the second one. We haven't achieved concurrency. This is like the "juggling cook" deciding to make one full burger from start to finish before even looking at the next customer's order.
The following video includes a superb animation that visualizes why this approach fails to provide any concurrency benefit.
Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations
Let's return to Corey Schafer's video. This section demonstrates the exact problem of sequential awaiting with a clear animation of the event loop's behavior.
Watch from 18:24 to 24:50. Pay close attention to the animation titled 'Example 2 - Sequential Async'. It shows how awaiting coroutine objects directly leads to sequential, not concurrent, execution.
The Solution: Concurrent Execution with asyncio.gather
To run our tasks concurrently, we need to give all our coroutine "recipes" to the event loop at once and then wait for them all to finish. The primary tool for this is asyncio.gather.
Here's how we modify our main function:
import asyncio
import time
# (asynchronous_task definition from above)
async def main_concurrent():
start_time = time.time()
print("Running tasks concurrently...")
# 1. Create a list of coroutine objects (the "recipes"). No code is run yet.
tasks_to_run = [
asynchronous_task(1, 2),
asynchronous_task(2, 3)
]
# 2. Use asyncio.gather to schedule and run them all concurrently.
# The '*' unpacks the list into separate arguments for gather.
results = await asyncio.gather(*tasks_to_run)
end_time = time.time()
print(f"Main concurrent finished in {end_time - start_time:.2f} seconds.")
print(f"Results: {results}")
if __name__ == "__main__":
asyncio.run(main_concurrent())
Now, the output will look drastically different:
Running tasks concurrently...
Async task 1: starting, will sleep for 2s.
Async task 2: starting, will sleep for 3s.
Async task 1: finished.
Async task 2: finished.
Main concurrent finished in 3.01 seconds.
Results: ['Result of task 1', 'Result of task 2']
Both tasks start almost simultaneously. The event loop runs task 1, sees it await asyncio.sleep(2), and while task 1 is sleeping, it switches to task 2. It runs task 2, sees it await asyncio.sleep(3), and now both are sleeping. After 2 seconds, task 1 finishes. After 3 seconds, task 2 finishes. The total time is determined by the longest-running task (3 seconds), not the sum of all tasks. This is the power of concurrency.
To understand gather and other ways to run tasks, let's watch one more clip.
Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations
This final clip from the same video introduces asyncio.gather and asyncio.TaskGroup as the proper ways to run multiple tasks concurrently.
Watch from 55:14 to 1:00:10. Focus on the explanation of asyncio.gather. Note how it takes multiple coroutines (or tasks) and runs them all at once. The video also mentions TaskGroup, which is a newer, often preferred alternative, but gather is a fundamental tool you need to know.
Your Turn: A Full Comparison
Let's bring it all together. The article below provides a fantastic, self-contained example comparing a synchronous script to a concurrent one using asyncio.gather, just like we did.
Python's asyncio: A Hands-On Walkthrough
The article 'Python's asyncio: A Hands-On Walkthrough' from Real Python offers a crisp, code-driven comparison. It's a great way to see the concepts we've discussed in a single, practical example.
Read the introductory section and compare the two code blocks, 'countsync.py' and 'countasync.py'. Pay attention to how async def, await asyncio.sleep, and asyncio.gather come together to dramatically reduce the execution time.
Conclusion
You've now taken your first practical steps into writing asynchronous Python code. This is the foundational skill for building high-performance FastAPI applications.
Key Takeaways:
- An
async deffunction, when called, returns a coroutine object—a recipe for a task. - The
awaitkeyword is used inside anasyncfunction to pause its execution and run an awaitable object, like another coroutine. - Awaiting coroutines sequentially (
await task1, thenawait task2) results in sequential execution, not concurrency. asyncio.gatheris the key to concurrency. It takes multiple coroutines and schedules them on the event loop to run at the same time, switching between them during I/O-bound waits.
You've moved from understanding the idea of concurrency to implementing it. In our next lesson, we will apply this knowledge directly within FastAPI. We'll explore the practical implications of choosing between async def and def for your route handlers and see exactly how FastAPI handles both to maximize performance.