Hello! Welcome to your next lesson on asyncio.
Introduction
In our last session, we diagnosed a key performance issue: using sequential await calls on I/O-bound coroutines results in no concurrency, making our async code behave just like synchronous code. The total runtime was the sum of all delays because the event loop was never given another task to work on during the waiting periods.
Today, we will solve that problem. This lesson focuses on the learning outcome: Use asyncio.create_task() to schedule coroutines for concurrent execution on the event loop. We will move from simply defining cooperative functions to actively scheduling them as independent tasks, which is the fundamental step to unlocking concurrency in asyncio.
Think of the event loop as a single-threaded scheduler. In the last lesson, we gave it one job, waited for it to finish, then gave it the next. Today, we'll learn how to submit a batch of jobs upfront, allowing the scheduler to intelligently switch between them to maximize efficiency.
1. From Coroutine to Task
First, we need to understand the distinction between a coroutine and a Task.
- A coroutine object, as we've seen, is created when you call an
async deffunction. It's an inert object that contains the code to be executed. - A
Taskis an object that wraps a coroutine and schedules it to run on theasyncioevent loop. Tasks are the primary mechanism for achieving concurrency. They areawaitableobjects, just like coroutines, but with the added ability to run independently in the "background."
The official Python documentation formally defines a Task as a Future-like object that runs a Python coroutine. For our purposes, you can think of it as a managed, schedulable unit of work.
The following video provides a concise explanation of what tasks are and how they differ from coroutines.
Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations
In his 'AsyncIO - Complete Guide' video, Corey Schafer clearly distinguishes coroutines from tasks. This section will formalize your understanding of what a Task represents.
Watch the segment from 12:13 to 13:57. Focus on the definition of a task as a 'wrapped coroutine' and the key idea that tasks are how we run coroutines concurrently.
2. Scheduling Coroutines with asyncio.create_task()
Now that we understand what a Task is, how do we create one? The primary high-level function for this is asyncio.create_task().
When you pass a coroutine object to asyncio.create_task(), it does two things:
- It wraps the coroutine in a
Taskobject. - It schedules the
Taskto run on the event loop as soon as possible.
The call to create_task() returns the Task object immediately, without waiting for the underlying coroutine to finish. This is the crucial difference from await. Your main coroutine can continue executing and create more tasks, effectively loading up the event loop with work.
Let's see this in action with a practical example.
The article 'Python asyncio.create_task(): Run Multiple Tasks Concurrently' from pythontutorial.net provides a clear, side-by-side comparison of sequential vs. concurrent execution.
Read the first two sections: 'Simulating a long-running operation' and 'Introduction to Python tasks'. The first section recaps the problem we already know (6-second execution). The second section shows the solution using asyncio.create_task(), achieving a 3-second execution. Pay close attention to the code structure: tasks are created first, and only awaited later.
As the reading demonstrates, the pattern is:
- Create all the tasks you want to run concurrently.
- Store the returned
Taskobjects. - At the point where you need their results,
awaittheTaskobjects.
3. Visualizing the Concurrent Execution Flow
The performance gain is clear, but to truly master this, it's essential to understand how the event loop orchestrates this. When a Task hits an await (like asyncio.sleep()), it yields control, and the event loop is free to run another Task that is ready.
The following animation visualizes this process perfectly.
Python Tutorial: AsyncIO - Complete Guide to Asynchronous Programming with Animations
Let's return to Corey Schafer's video. He provides an excellent animation that contrasts the sequential execution flow with the concurrent flow enabled by asyncio.create_task().
Watch the animation from 27:15 to 31:53. Observe how creating tasks schedules them on the event loop before they are awaited. Notice the key moment when the first task suspends on sleep(), and the event loop immediately switches to run the second task. This is the core of asyncio concurrency.
This animation makes the abstract concept of the event loop's scheduling tangible. Both tasks are on the loop's "to-do" list. When one says "I'm waiting," the loop simply picks up the next one.
4. Your Turn: Refactor for Concurrency
Now, let's apply this knowledge. Below is the sequential code from our previous lesson. Your task is to modify the main coroutine to run the two fetch_data calls concurrently using asyncio.create_task().
Your Code to Modify:
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": f"Data for {data_id}"}
async def main():
"""The main entry point for our program."""
start_time = time.perf_counter()
print("--- Sequential Execution ---")
result1 = await fetch_data(1, 2)
result2 = await fetch_data(2, 3)
print(f"Result 1: {result1}")
print(f"Result 2: {result2}")
end_time = time.perf_counter()
print(f"Total time: {end_time - start_time:.2f} seconds")
if __name__ == "__main__":
asyncio.run(main())
Instructions:
- Modify the
mainfunction. - Create a task for
fetch_data(1, 2). - Create a task for
fetch_data(2, 3). awaitboth tasks to retrieve their results.- Run the code and verify the output.
Expected Output:
The order of the "Starting..." and "Finished..." messages might vary, but the total time should be approximately 3 seconds (the duration of the longest task), not 5.
--- Concurrent Execution ---
Starting to fetch data 1...
Starting to fetch data 2...
Finished fetching data 1.
Finished fetching data 2.
Result 1: {'data_id': 1, 'content': 'Data for 1'}
Result 2: {'data_id': 2, 'content': 'Data for 2'}
Total time: 3.00 seconds
Click here for the solution
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": f"Data for {data_id}"}
async def main():
"""The main entry point for our program."""
start_time = time.perf_counter()
print("--- Concurrent Execution ---")
# 1. Create tasks and schedule them on the event loop
task1 = asyncio.create_task(fetch_data(1, 2))
task2 = asyncio.create_task(fetch_data(2, 3))
# 2. Await the completion of the tasks to get their results
# The event loop runs other tasks while main() awaits task1.
result1 = await task1
result2 = await task2
print(f"Result 1: {result1}")
print(f"Result 2: {result2}")
end_time = time.perf_counter()
print(f"Total time: {end_time - start_time:.2f} seconds")
if __name__ == "__main__":
asyncio.run(main())
5. Important Considerations
There are two critical details to remember when working with create_task.
1. You Must await the Task (Eventually)
Creating a task schedules it, but if your program finishes before the task does, it may be cancelled abruptly. Awaiting the task ensures it completes and allows you to retrieve its result or handle any exceptions it might have raised. A common mistake is to create "fire-and-forget" tasks without awaiting them, leading to unpredictable behavior.
2. Keep a Strong Reference to the Task
The asyncio event loop only keeps weak references to tasks. If you create a task and don't store the returned object in a variable, it can be garbage collected mid-execution.
# Unsafe: The task might be garbage collected
asyncio.create_task(some_long_running_coro())
# Safe: We hold a reference to the task
task = asyncio.create_task(some_long_running_coro())
# ... later ...
await task
For managing many background tasks, the official documentation recommends gathering them in a collection like a set to maintain strong references.
The official Python documentation for 'Coroutines and Tasks' contains an important note about this behavior.
Read the 'Important' block within the 'Creating Tasks' section. It explains why you must save a reference to the result of asyncio.create_task() and provides a code snippet for managing a collection of background tasks.
Conclusion
In this lesson, we've made the pivotal leap from sequential to concurrent asynchronous code. You now have the primary tool for unleashing the power of the asyncio event loop.
Key Takeaways:
- An
asyncio.Taskis a schedulable wrapper around a coroutine. asyncio.create_task(coro)schedules the coroutine to run on the event loop and immediately returns aTaskobject.- This allows you to schedule multiple I/O-bound operations that can run concurrently, with the event loop switching between them during idle periods.
- To get a task's result and ensure its completion, you must
awaittheTaskobject at a later point. - Always maintain a strong reference to a created task to prevent it from being garbage collected prematurely.
Preview of the Next Lesson:
Creating and awaiting tasks individually works, but it can become verbose when dealing with many tasks. In the next lesson, we will explore asyncio.gather(), a convenient and powerful utility for running multiple coroutines concurrently and collecting their results in a more structured way.
Can't find a good explanation? Sign up and we'll make it for you
Sign up