Last Updated on November 21, 2023
A problem with tasks is that it is a good idea to assign and keep track of the asyncio.Task objects.
The reason is that if we don’t the tasks may be garbage collected, terminating the task.
A helpful solution is to use a TaskGroup to create and manage a collection of tasks. It has a number of benefits, such as canceling all tasks in the group if one task fails.
In this tutorial, you will discover how to use the TaskGroup in asyncio.
After completing this tutorial, you will know:
- How to use the TaskGroup to create and manage a collection of tasks.
- How to wait for a collection of tasks to complete using the TaskGroup context manager interface.
- How to cancel all tasks if one task in the TaskGroup fails.
Let’s get started.
Need To Manage Multiple Coroutines as a Group
It is common to issue many coroutines and then manage them as a group.
Treating multiple coroutines as a group allows for functionality such as:
- Waiting until all tasks are completed.
- Canceling all tasks if one task fails.
- Handling an exception raised in any task.
Prior to Python 3.11, there were two main approaches to handling multiple coroutines as a group, they were:
- Call asyncio.gather()
- Call asyncio.wait()
Manage Multiple Coroutines with asyncio.gather()
The asyncio.gather() function takes one more coroutine or asyncio.Task objects.
It returns a Future object that allows the group of tasks to be managed together with features such as canceling all tasks and waiting on all tasks.
You can learn more about how to use the asyncio.gather() function in the tutorial:
It is possible to use cancel all tasks in the group if one task in the group fails with an exception.
You can see an example of this in the tutorial:
Manage Multiple Coroutines with asyncio.wait()
The asyncio.wait() function takes a collection of coroutines or tasks and returns the set of tasks that meet the specified conditions, such as one completed, all completed or first to fail.
You can learn more about how to use the asyncio.wait() function in the tutorial:
The release of Python version 3.11 introduced a new approach to managing multiple coroutines or tasks as a group, called the asyncio.TaskGroup.
Run loops using all CPUs, download your FREE book to learn how.
How to Use asyncio.TaskGroup
Python 3.11 introduce the asyncio.TaskGroup task for managing a group of associated asyncio task.
Added the TaskGroup class, an asynchronous context manager holding a group of tasks that will wait for all of them upon exit. For new code this is recommended over using create_task() and gather() directly.
— What’s New In Python 3.11
The asyncio.TaskGroup class is intended as a replacement for the asyncio.create_task() function for creating tasks and the asyncio.gather() function for waiting on a group of tasks.
Historically, we create and issue a coroutine as an asyncio.Task using the asyncio.create_task() function.
For example:
1 2 3 |
... # create and issue coroutine as task task = asyncio.create_task(coro()) |
This creates a new asyncio.Task object and issues it to the asyncio event loop for execution as soon as it is able.
We can then choose to await the task and wait for it to be completed.
For example:
1 2 3 |
... # wait for task to complete result = await task |
You can learn more about executing coroutines as asyncio.Task objects in the tutorial:
As we have seen, the asyncio.gather() function is used to create and issue many coroutines simultaneously as asyncio.Task objects to the event loop, allowing the caller to treat them all as a group.
The most common usage is to wait for all issued tasks to complete.
For example:
1 2 3 |
... # issue coroutines as tasks and wait for them to complete results = await asyncio.gather(coro1(), coro2(), coro2) |
The asyncio.TaskGroup can perform both of these activities and is the preferred approach.
An asynchronous context manager holding a group of tasks. Tasks can be added to the group using create_task(). All tasks are awaited when the context manager exits.
— Asyncio Task Groups
How to Create an asyncio.TaskGroup
An asyncio.TaskGroup object implements the asynchronous context manager interface, and this is the preferred usage of the class.
This means that an instance of the class is created and is used via the “async with” expression.
For example:
1 2 3 4 |
... # create a taskgroup async with asyncio.TaskGroup() as group: # ... |
If you are new to the “async with” expression, see the tutorial:
Recall that an asynchronous context manager implements the __aenter__() and __aexit__() methods which can be awaited.
In the case of the asyncio.TaskGroup, the __aexit__() method which is called automatically when the context manager block is exited will await all tasks created by the asyncio.TaskGroup.
This means that exiting the TaskGroup object’s block normally or via an exception will automatically await until all group tasks are done.
1 2 3 4 5 |
... # create a taskgroup async with asyncio.TaskGroup() as group: # ... # wait for all group tasks are done |
You can learn more about asynchronous context managers in the tutorial:
How to Create Tasks Using asyncio.TaskGroup
We can create a task in the task group via the create_task() method on the asyncio.TaskGroup object.
For example:
1 2 3 4 5 |
... # create a taskgroup async with asyncio.TaskGroup() as group: # create and issue a task task = group.create_task(coro()) |
This will create an asyncio.Task object and issue it to the asyncio event loop for execution, just like the asyncio.create_task() function, except that the task is associated with the group.
We can await the task directly if we choose and get results.
For example:
1 2 3 4 5 |
... # create a taskgroup async with asyncio.TaskGroup() as group: # create and issue a task result = await group.create_task(coro()) |
The benefit of using the asyncio.TaskGroup is that we can issue multiple tasks in the group and execute code in between. such as checking results or gathering more data.
How to Wait on Tasks Using asyncio.TaskGroup
We can wait on all tasks in the group by exiting the asynchronous context manager block.
As such, the tasks are awaited automatically and nothing additional is required.
For example:
1 2 3 4 5 |
... # create a taskgroup async with asyncio.TaskGroup() as group: # ... # wait for all group tasks are done |
If this behavior is not preferred, then we must ensure all tasks are “done” (finished, canceled, or failed) before exiting the context manager.
How to Cancel All Tasks If One Task Fails Using asyncio.TaskGroup
If one task in the group fails with an exception, then all non-done tasks remaining in the group will be canceled.
This is performed automatically and does not require any additional code.
For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# handle the failure of any tasks in the group try: ... # create a taskgroup async with asyncio.TaskGroup() as group: # create and issue a task task1 = group.create_task(coro1()) # create and issue a task task2 = group.create_task(coro2()) # create and issue a task task3 = group.create_task(coro3()) # wait for all group tasks are done except: # all non-done tasks are cancelled pass |
If this behavior is not preferred, then the failure of each task must be managed within the tasks themselves, e.g. by a try-except block within the coroutine.
Now that we know how to use the asyncio.TaskGroup, let’s look at some worked examples.
Example of Waiting on Multiple Tasks with a TaskGroup
We can explore the case of creating multiple tasks within an asyncio.TaskGroup and then waiting for all tasks to complete.
This can be achieved by first defining a suite of different coroutines that represent the tasks we want to complete.
In this case, we will define 3 coroutines that each report a different message and then sleep for one second.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
# coroutine task async def task1(): # report a message print('Hello from coroutine 1') # sleep to simulate waiting await asyncio.sleep(1) # coroutine task async def task2(): # report a message print('Hello from coroutine 2') # sleep to simulate waiting await asyncio.sleep(1) # coroutine task async def task3(): # report a message print('Hello from coroutine 3') # sleep to simulate waiting await asyncio.sleep(1) |
Next, we can define a main() coroutine that creates the asyncio.TaskGroup via the context manager interface.
1 2 3 4 5 |
# asyncio entry point async def main(): # create task group async with asyncio.TaskGroup() as group: # ... |
We can then create and issue each coroutine as a task into the event loop, although collected together as part of the group.
1 2 3 4 5 6 7 |
... # run first task group.create_task(task1()) # run second task group.create_task(task2()) # run third task group.create_task(task3()) |
Notice that we don’t need to keep a reference to the asyncio.Task objects as the asyncio.TaskGroup will keep track of them for us.
Also, notice that we don’t need to await the tasks because when we exit the context manager block for the asyncio.TaskGroup we will await all tasks in the group.
Tying this together, the complete example is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
# example of asyncio task group import asyncio # coroutine task async def task1(): # report a message print('Hello from coroutine 1') # sleep to simulate waiting await asyncio.sleep(1) # coroutine task async def task2(): # report a message print('Hello from coroutine 2') # sleep to simulate waiting await asyncio.sleep(1) # coroutine task async def task3(): # report a message print('Hello from coroutine 3') # sleep to simulate waiting await asyncio.sleep(1) # asyncio entry point async def main(): # create task group async with asyncio.TaskGroup() as group: # run first task group.create_task(task1()) # run second task group.create_task(task2()) # run third task group.create_task(task3()) # wait for all tasks to complete... print('Done') # entry point asyncio.run(main()) |
Running the example first executes the main() coroutine, starting a new event loop for us.
The main() coroutine runs and creates an asyncio.TaskGroup.
All three coroutines are then created as asyncio.Task objects and issued to the event loop via the asyncio.TaskGroup.
The context manager block for the asyncio.TaskGroup is exited which automatically awaits all three tasks.
The tasks report their message and sleep.
Once all tasks are completed the main() coroutine reports a final message.
1 2 3 4 |
Hello from coroutine 1 Hello from coroutine 2 Hello from coroutine 3 Done |
Next, let’s explore how we might use an asyncio.TaskGroup with tasks that take arguments and return values.
Free Python Asyncio Course
Download your FREE Asyncio PDF cheat sheet and get BONUS access to my free 7-day crash course on the Asyncio API.
Discover how to use the Python asyncio module including how to define, create, and run new coroutines and how to use non-blocking I/O.
Example of TaskGroup with Arguments and Return Values
We can explore the case of executing coroutines as tasks that take arguments and return values.
These are just like coroutines we might issue normally as tasks without the asyncio.TaskGroup, but it is good to have an example for reference.
In this case, we will define a task that takes an argument, sleeps, then returns the argument multiplied by 100.
1 2 3 4 5 6 |
# coroutine task async def task(value): # sleep to simulate waiting await asyncio.sleep(1) # return value return value * 100 |
The main coroutine will then create an asyncio.TaskGroup and then create 9 instances of the task, passing the value 1 to 9 as arguments to the task.
The task objects are kept so we can retrieve the values from them later. This is achieved using a list comprehension.
Once all tasks are complete, the return values are retrieved and reported.
1 2 3 4 5 6 7 8 9 10 |
# asyncio entry point async def main(): # create task group async with asyncio.TaskGroup() as group: # create and issue tasks tasks = [group.create_task(task(i)) for i in range(1,10)] # wait for all tasks to complete... # report all results for t in tasks: print(t.result()) |
Tying this together, the complete example is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
# example of asyncio task group with return values import asyncio # coroutine task async def task(value): # sleep to simulate waiting await asyncio.sleep(1) # return value return value * 100 # asyncio entry point async def main(): # create task group async with asyncio.TaskGroup() as group: # create and issue tasks tasks = [group.create_task(task(i)) for i in range(1,10)] # wait for all tasks to complete... # report all results for t in tasks: print(t.result()) # entry point asyncio.run(main()) |
Running the example first executes the main() coroutine, starting a new event loop for us.
The main() coroutine runs and creates an asyncio.TaskGroup.
A total of 9 coroutines are issued as tasks via the asyncio.TaskGroup and the asyncio.Task objects are stored in a list.
The main() coroutine then awaits all tasks.
Each task runs, sleeps, then returns its input argument multiples by one hundred.
Once all tasks are complete, the asyncio.Task objects are iterated and the return value is reported from each.
This shows how we might pass arguments to tasks created via the asyncio.TaskGroup and how we might keep track of asyncio.Task objects in order to manually retrieve results from each task at a later stage.
1 2 3 4 5 6 7 8 9 |
100 200 300 400 500 600 700 800 900 |
Next, let’s look at an example of canceling all tasks in the group if one task fails.
Overwhelmed by the python concurrency APIs?
Find relief, download my FREE Python Concurrency Mind Maps
Example of Cancelling All Tasks if One Task Fails Using TaskGroup
We can explore the case of canceling all tasks in the asyncio.TaskGroup if one task fails.
A failed task means that a coroutine is executed in an asyncio.Task object that raises an exception that is not handled in the coroutine, meaning that it bubbles up to the task and causes the task to be halted early.
It is common to issue many tasks and cancel all tasks if one or more of the tasks fails.
The asyncio.TaskGroup will perform this action automatically for us.
In this case, we will define 3 different coroutines that report a message and sleep. The second coroutine will then fail with an uncaught exception.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
# coroutine task async def task1(): # report a message print('Hello from coroutine 1') # sleep to simulate waiting await asyncio.sleep(1) # coroutine task async def task2(): # report a message print('Hello from coroutine 2') # sleep to simulate waiting await asyncio.sleep(0.5) # fail with an exception raise Exception('Something bad happened') # coroutine task async def task3(): # report a message print('Hello from coroutine 2') # sleep to simulate waiting await asyncio.sleep(1) |
Note that the second task sleeps less than the other two tasks before raising an exception.
This is to ensure that the other two tasks are still running at the point that the second task fails so that we can see if they are canceled as we expect.
The main() coroutine will issue all tasks via the asyncio.TaskGroup and then report the done and cancel status of each in turn once all tasks are “done”.
Recall a “done” task is a task that is finished normally, canceled, or failed with an exception.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# asyncio entry point async def main(): # handle exceptions try: # create task group async with asyncio.TaskGroup() as group: # run first task t1 = group.create_task(task1()) # run second task t2 = group.create_task(task2()) # run third task t3 = group.create_task(task3()) except: pass # check the status of each task print(f'Task1: done={t1.done()}, cancelled={t1.cancelled()}') print(f'Task2: done={t2.done()}, cancelled={t2.cancelled()}') print(f'Task3: done={t3.done()}, cancelled={t3.cancelled()}') |
Notice that we wrap the entire asyncio.TaskGroup in an exception as any uncaught exception that occurs in a task is re-raised by the asyncio.TaskGroup
Tying this together, the complete example is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
# example of asyncio task group with a failed task import asyncio # coroutine task async def task1(): # report a message print('Hello from coroutine 1') # sleep to simulate waiting await asyncio.sleep(1) # coroutine task async def task2(): # report a message print('Hello from coroutine 2') # sleep to simulate waiting await asyncio.sleep(0.5) # fail with an exception raise Exception('Something bad happened') # coroutine task async def task3(): # report a message print('Hello from coroutine 2') # sleep to simulate waiting await asyncio.sleep(1) # asyncio entry point async def main(): # handle exceptions try: # create task group async with asyncio.TaskGroup() as group: # run first task t1 = group.create_task(task1()) # run second task t2 = group.create_task(task2()) # run third task t3 = group.create_task(task3()) except: pass # check the status of each task print(f'Task1: done={t1.done()}, cancelled={t1.cancelled()}') print(f'Task2: done={t2.done()}, cancelled={t2.cancelled()}') print(f'Task3: done={t3.done()}, cancelled={t3.cancelled()}') # entry point asyncio.run(main()) |
Running the example first executes the main() coroutine, starting a new event loop for us.
The main() coroutine runs and creates an asyncio.TaskGroup.
The three coroutines are then issued as tasks via the asyncio.TaskGroup and the asyncio.Task objects are kept in local variables for later.
The asyncio.TaskGroup context manager block is exited and the main() coroutine then awaits all three tasks.
The tasks run, report a message and sleep. The second coroutine then fails with an exception.
The asyncio.TaskGroup handles the exception and cancels all remaining not-done tasks. The exception is then re-raised at the top level and ignored.
The done and canceled status of each task is then reported. We can see that all tasks are done and that the two tasks (1 and 3) that were running at the time task 2 failed with an exception were indeed canceled.
This highlights how all running tasks in the group will be canceled if a task in the group fails with an unhanded exception.
It is possible to shield a task from cancellation. You can learn more about this in the tutorial:
1 2 3 4 5 6 |
Hello from coroutine 1 Hello from coroutine 2 Hello from coroutine 2 Task1: done=True, cancelled=True Task2: done=True, cancelled=False Task3: done=True, cancelled=True |
Next, let’s look at an example of manually canceling one task in the group.
Example of Cancelling One Task in a TaskGroup
We can explore the case of manually canceling one task in the group.
This can be achieved by calling the cancel() method on the asyncio.Task object.
If the task is not done, the request to cancel the task will be handled by the task.
You can learn more about canceling tasks in the tutorial:
In this case, we will issue 3 tasks using the asyncio.TaskGroup, wait a moment, then cancel the second task.
The expectation is that only the second task will be canceled and all other tasks will be left to run as per normal. We will confirm this by reporting the “done” and “cancelled” status of all tasks after all tasks are done.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
# asyncio entry point async def main(): # create task group async with asyncio.TaskGroup() as group: # run first task t1 = group.create_task(task1()) # run second task t2 = group.create_task(task2()) # run third task t3 = group.create_task(task3()) # wait a moment await asyncio.sleep(0.5) # cancel the second task t2.cancel() # check the status of each task print(f'Task1: done={t1.done()}, cancelled={t1.cancelled()}') print(f'Task2: done={t2.done()}, cancelled={t2.cancelled()}') print(f'Task3: done={t3.done()}, cancelled={t3.cancelled()}') |
Tying this together, the complete example is listed below.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
# example of asyncio task group with a canceled task import asyncio # coroutine task async def task1(): # sleep to simulate waiting await asyncio.sleep(1) # report a message print('Hello from coroutine 1') # coroutine task async def task2(): # sleep to simulate waiting await asyncio.sleep(1) # report a message print('Hello from coroutine 2') # coroutine task async def task3(): # sleep to simulate waiting await asyncio.sleep(1) # report a message print('Hello from coroutine 2') # asyncio entry point async def main(): # create task group async with asyncio.TaskGroup() as group: # run first task t1 = group.create_task(task1()) # run second task t2 = group.create_task(task2()) # run third task t3 = group.create_task(task3()) # wait a moment await asyncio.sleep(0.5) # cancel the second task t2.cancel() # check the status of each task print(f'Task1: done={t1.done()}, cancelled={t1.cancelled()}') print(f'Task2: done={t2.done()}, cancelled={t2.cancelled()}') print(f'Task3: done={t3.done()}, cancelled={t3.cancelled()}') # entry point asyncio.run(main()) |
Running the example first executes the main() coroutine, starting a new event loop for us.
The main() coroutine runs and creates an asyncio.TaskGroup.
The three coroutines are then issued as tasks via the asyncio.TaskGroup and the asyncio.Task objects are kept in local variables for later.
The main() coroutine sleeps for a moment, allowing the tasks to run.
The main() coroutine results and then cancels the second task. It then exits the context manager of the asyncio.TaskGroup and awaits all tasks.
The second task is canceled. The remaining tasks complete normally. We see messages from tasks 1 and 3 only because task 2 was canceled before the message could be reported.
Checking the status of the tasks, we can see that all tasks are done and only task 2 was canceled.
This highlights that we can manually cancel tasks in the group, leaving other tasks unaffected.
1 2 3 4 5 |
Hello from coroutine 1 Hello from coroutine 2 Task1: done=True, cancelled=False Task2: done=True, cancelled=True Task3: done=True, cancelled=False |
Further Reading
This section provides additional resources that you may find helpful.
Python Asyncio Books
- Python Asyncio Mastery, Jason Brownlee (my book!)
- Python Asyncio Jump-Start, Jason Brownlee.
- Python Asyncio Interview Questions, Jason Brownlee.
- Asyncio Module API Cheat Sheet
I also recommend the following books:
- Python Concurrency with asyncio, Matthew Fowler, 2022.
- Using Asyncio in Python, Caleb Hattingh, 2020.
- asyncio Recipes, Mohamed Mustapha Tahrioui, 2019.
Guides
APIs
- asyncio — Asynchronous I/O
- Asyncio Coroutines and Tasks
- Asyncio Streams
- Asyncio Subprocesses
- Asyncio Queues
- Asyncio Synchronization Primitives
References
Takeaways
You now know how to use the asyncio.TaskGroup in Python.
Do you have any questions?
Ask your questions in the comments below and I will do my best to answer.
Photo by Jonathan Cooper on Unsplash
Phil says
“The asyncio.TaskGroup can perform both of these activities and is not the preferred approach.”
typo here maybe? not=now
Jason Brownlee says
Thank you! Oops, fixed.
Iñaki says
Hi Jason, thank you for this article it’s been very helpful. I have a question:
How would you cancel all remaining tasks after one of them has ended successfully? Say you have 10 ways to achieving the same goal, each of this ways might or might not succeed and they consume different amount of resources so they’ll complete in varying amounts of time. Essentially I want to halt all remaining tasks as soon as one of them yields a success.
Iñaki
Jason Brownlee says
Thanks!
Great question. You can keep a reference to all tasks, then use asyncio.wait() to wait on the first to complete, then iterate over the list and call cancel() on each in turn. This will cancel all remaining tasks.
Learn more about how to use asyncio.wait() to wait for the first task to complete here:
https://superfastpython.com/asyncio-wait/
Learn more about canceling tasks here:
https://superfastpython.com/asyncio-cancel-task/
Does that help?