Last Updated on December 20, 2023
Tasks in asyncio can be canceled manually and automatically.
Therefore, we must develop asyncio programs with the expectation that our custom tasks may be canceled at any time. This requires a certain level of robustness.
Thankfully there are common task cancellation idioms and best practices that we can use throughout our asyncio programs.
In this tutorial, you will discover best practices for canceling asyncio tasks in Python.
Let’s get started.
What is Task Cancellation?
Asyncio tasks can be canceled.
This can be achieved by calling the cancel() method on the asyncio.Task. This will request that the task be canceled as soon as possible and returns True if the request was successful or False if it was not, e.g. the task is already done.
Request the Task to be cancelled. This arranges for a CancelledError exception to be thrown into the wrapped coroutine on the next cycle of the event loop.
— Coroutines and Tasks
For example:
1 2 3 4 |
... # cancel a task if task.cancel(): # request successful |
The next time that the target task that was canceled resumes, a CancelledError exception will be raised.
If the target task is awaiting another task, the request to cancel will propagate down.
If a coroutine is awaiting on a Future object during cancellation, the Future object will be cancelled.
— Coroutines and Tasks
This exception will bubble up to the top level of the task and cause it to stop running.
Therefore, any users of the task that get a return value, result, or exception should handle a possible exception.
For example:
1 2 3 4 5 6 |
... try: # await a task result = await task except asyncio.CancelledError: # handle cancellation |
We can check if a task has successfully been requested to cancel via the cancelling() method. This will return the number of requests for cancellation the task has received as an integer.
1 2 3 4 |
... # check if requested to cancel if task.cancelling(): # has had at least one request to cancel |
Return the number of pending cancellation requests to this Task, i.e., the number of calls to cancel() less the number of uncancel() calls.
— Coroutines and Tasks
We can check if a task was canceled via the cancelled() method, which will return true if the task failed with a CancelledError.
Return True if the Task is cancelled.
— Coroutines and Tasks
For example:
1 2 3 4 |
... # check if cancelled if task.cancelled(): # was cancelled |
You can learn more about the mechanics of cancellation a task in the tutorial:
Next, let’s consider when a task can be canceled.
Run loops using all CPUs, download your FREE book to learn how.
When Are Tasks Cancelled?
A task can be canceled only if it is pending (scheduled) or running.
A done task cannot be canceled.
There are two ways a task may be canceled:
- Manually
- Automatically
- Timeouts
- Failure in Group
- Event Loop Shutdown
Manually Cancelling a Task
A task can be canceled manually by another task calling the cancel() method.
For example:
1 2 3 |
... # cancel a task _ = task.cancel() |
That’s about it.
Next, let’s consider some of the ways that a task may be automatically canceled.
Automatically Cancelling a Task
A task may be canceled automatically by infrastructure in the asyncio module.
This includes:
- Tasks are canceled automatically after a timeout.
- Tasks are canceled automatically when another task in the group fails.
- Tasks are canceled automatically when the event loop is terminated.
A task may be canceled automatically by a timeout.
This can be achieved via the asyncio.wait_for() call which will cancel the target task after a fixed number of seconds by raising a CancelledError in the task and transforming it into a TimeoutError.
If a timeout occurs, it cancels the task and raises TimeoutError.
— Coroutines and Tasks
For example:
1 2 3 4 5 6 7 |
... # handle timeout try: # cancel task after a timeout asyncio.wait_for(task, timeout=5) except asyncio.TimeoutError: # handle timeout |
You can learn more about the asyncio.wait_for() call in the tutorial:
A task may also be canceled by a timeout when using the asyncio.timeout() or asyncio.timeout_at() context managers.
Like asyncio.wait_for(), the timeout context managers will cancel the target task and raise an asyncio.TimeoutError if the timeout or deadline elapses. The difference is the context manager allows multiple tasks to be created and canceled.
the context manager will cancel the current task and handle the resulting asyncio.CancelledError internally, transforming it into a TimeoutError which can be caught and handled.
— Coroutines and Tasks
For example:
1 2 3 4 5 6 7 8 9 10 |
... # handle timeout try: # run tasks with timeout asyncio with asyncio.timeout(5): await task1 await task2 await task3 except asyncio.TimeoutError: # handle timeout |
You can learn more about the asyncio.timeout() context manager in the tutorial:
Tasks may also be canceled automatically if they are part of a group and one task in the group is canceled.
This can be achieved by creating tasks as part of an asyncio.TaskGroup. If one task in the group fails with an unhandled exception, all remaining tasks in the group will be canceled.
The first time any of the tasks belonging to the group fails with an exception other than asyncio.CancelledError, the remaining tasks in the group are cancelled.
— Coroutines and Tasks
For example:
1 2 3 4 5 6 7 8 9 10 |
... # handle exceptions try: # create task group async with asyncio.TaskGroup() as group: await group.create_task(task1()) await group.create_task(task2()) await group.create_task(task3()) except asyncio.CancelledError: # handle timeout |
You can learn more about how to use asyncio.TaskGroup in the tutorial:
Finally, tasks will also be canceled when the asyncio event loop is shut down.
This may be when the main coroutine terminates normally, or it may be when the event loop is terminated via an unexpected exception.
You can learn more about when asyncio tasks are canceled in the tutorial:
Now that we know about task cancellation and when tasks are canceled, let’s consider some common misunderstandings.
Task Cancellation Misunderstandings
This section considers some common task cancellation misunderstandings, they are:
- Calling Task.cancel() does not cancel the task.
- Calling Task.cancel() does not block until the task is canceled.
Let’s take a closer look at each of these practices in turn.
Calling cancel() Does Not Cancel The Task
Calling the cancel() method will not cancel the task.
Instead, the cancel() method will request that the target task cancel.
This request may succeed or fail.
For example, if the task is already done, it cannot be canceled, and this request to cancel will fail.
We know if the request to cancel succeeded or failed via the return value.
For example:
1 2 3 4 |
... # cancel a task if task.cancel(): # request successful |
Calling cancel() Does Not Block Until The Task is Cancelled
The call to cancel() will return immediately.
It does not block until the target task is canceled.
The call is a request to cancel the target task and returns a boolean True if the request was successful, False otherwise.
If the caller needs to know the task is canceled and done, it could await for the target to respond to the request for cancellation (e.g. actually cancel or handle the cancellation).
For example:
1 2 3 4 5 6 7 8 |
... # cancel a task if task.cancel(): # handle cancellation try: await task except asyncio.CancelledError: pass |
Although this is a synchronous dependency and is not recommended.
See best practices.
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.
Task Cancellation Best Practices
This section lists some best practices related to task cancellation in asyncio, they are:
- Do not consume the CancelledError (without a good reason).
- Assume a task will be canceled from the outside.
- Assume a task will be canceled from the inside.
- Use the message argument when manually canceling a task.
- Don’t cancel then wait to cancel (without a good reason).
- Don’t assume requests to cancel actually cancel the task.
Some of these are pretty opinionated.
Perhaps consider them as recommendations (or land mines) if you’re chafing.
Let’s take a closer look at each of these practices in turn.
Overwhelmed by the python concurrency APIs?
Find relief, download my FREE Python Concurrency Mind Maps
Practice #1: Do Not Consume CancelledError
Generally consuming a CancelledError is not a good practice.
It is recommended that coroutines use try/finally blocks to robustly perform clean-up logic. In case asyncio.CancelledError is explicitly caught, it should generally be propagated when clean-up is complete.
— Coroutines and Tasks
There are two main cases when we might try and consume a CancelledError exception, they are:
- Consume a CancelledError outside the task.
- Consume a CancelledError within the task.
Let’s consider each case.
Consume a CancelledError Outside The Task
A good practice when working with an asyncio task is to assume it is going to be canceled.
Therefore, we wrap working with the task in a try-except block for the CancelledError.
For example:
1 2 3 4 5 |
... try: # operate on task except asyncio.CancelledError: # handle |
The problem with this idiom is that the current task may also be canceled at any time.
If the current task is canceled while operating on the task, it will raise a CancelledError which will be consumed by the try-except block.
Therefore when handling an asyncio.CancelledError we should confirm the desired state.
For example, we could check that the target task has indeed been canceled.
For example:
1 2 3 4 5 6 |
... try: # operate on task except asyncio.CancelledError: if task.done(): # handle cancellation of task |
We might also want to check if the current task has been requested to cancel and we may have accidentally consumed this exception.
This can be achieved by calling the current_task() function to get the current task and the cancelling() method which will return the number of requests the current task has received to cancel.
If the current task has been requested to cancel and we consumed it, we can let that exception propagate up normally.
For example, it could look something like:
1 2 3 4 5 6 7 8 9 10 11 |
... try: # operate on task except asyncio.CancelledError: # confirm the target task is done if task.done(): # handle cancellation of task # check if the current task has been requested to cancel if asyncio.current_task().cancelling() > 0: # propagate the exception up normally raise |
Consume a CancelledError Inside The Task
We may want to wrap the body of a task with a try-except and handle the CancelledError.
For example:
1 2 3 4 5 6 7 |
... # custom coroutine task async def task(): try: # body... except asyncio.CancelledError # clean up |
The problem with this pattern is that the task will consume the CancelledError.
This may cause unexpected behavior outside the task for other tasks that may expect the task to cancel or be canceled.
For example, another task may cancel the task and wait for it to be canceled. The target task will never cancel in this case because it cannot raise the expected asyncio.CancelledError (except if it was canceled while pending).
Calls to the cancelled() method on the task will return False for a task that was “cancelled” externally, which internally consumed the CancelledError exception.
Therefore, we only want to catch the asyncio.CancelledError if we have a good reason, e.g. ensure the task can never be canceled after it has started running.
If that was the case, we should consider using a shield via asyncio.shield(), negating the need for this pattern.
You can learn more about asyncio.shield() in the tutorial:
If you need to perform cleanup within a task when it is canceled, you could use a try-finally block.
For example:
1 2 3 4 5 6 |
# custom coroutine task async def task(): try: # body... finally # clean up |
Or, you could handle the CancelledError, and then re-raise it.
For example:
1 2 3 4 5 6 7 8 9 |
# custom coroutine task async def task(): try: # body... except asyncio.CancelledError # clean up # ... # re-raise the cancellation raise |
When to Consume the CancelledError
So, when should we handle CancelledError?
This is application-specific.
The general rule for exception handling holds:
- Handle the CancelledError at the scope at which the application can take action.
The most reasonable answer is to handle the CancelledError at the scope that made the request to cancel.
This applies if the request came from asyncio infrastructure, such as timeouts and failures in a task group.
Practice #2: Assume a Task Will Be Canceled From The Outside
When working with an asyncio task, assume that it will be canceled.
This is a good practice because:
- It forces us to handle the CancelledError.
- It forces consideration of what cancellation means in the program
- It forces some cancellation activity (e.g. retry, log, shutdown, etc.)
Recall, a task may be canceled both manually and automatically, such as via a timeout, via another task failure, and when shutting down the event loop. It is reasonable to think a given task could be canceled at any time.
Assuming a task will be canceled, means we need to handle a CancelledError exception when working with the task.
There are three main areas when working with the task from the outside when we should expect a CancelledError exception, they are:
- When awaiting the task. e.g. await task
- When getting the result from the task, e.g. task.result()
- When getting the error from the task, e.g. task.exception()
Let’s take a closer look at each.
How to Try CancelledError
We can wrap these external task activities in a try-except block and handle CancelledError.
For example:
1 2 3 4 5 |
... try: # operate on task except asyncio.CancelledError: # handle |
A problem with this is it may consume requests for the current task to cancel.
You should consider checking for this case (described above).
For example:
1 2 3 4 5 6 7 8 9 10 11 |
... try: # operate on task except asyncio.CancelledError: # confirm the target is done if task.done(): # handle cancellation of task # check if the current task has been requested to cancel if asyncio.current_task().cancelling() > 0: # propagate the exception up normally raise |
This does feel heavy.
Perhaps consider this carefully and whether you need to be so defensive.
Check CancelledError When Awaiting
When awaiting a task, we should expect that it will fail and raise a CancelledError.
1 2 3 4 5 6 7 8 9 10 11 12 |
... try: # await the task await task except asyncio.CancelledError: # confirm the target is done if task.done(): # handle cancellation of task # check if the current task has been requested to cancel if asyncio.current_task().cancelling() > 0: # propagate the exception up normally raise |
Check CancelledError When Getting Result
When getting a result from a done task, we should expect that it will fail and raise a CancelledError.
1 2 3 4 5 6 7 8 9 10 11 12 |
... try: # get the result from a done task result = task.result() except asyncio.CancelledError: # confirm the target is done if task.done(): # handle cancellation of task # check if the current task has been requested to cancel if asyncio.current_task().cancelling() > 0: # propagate the exception up normally raise |
Check CancelledError When Getting Exception
When getting an exception from a done task, we should expect that it will fail and raise a CancelledError.
1 2 3 4 5 6 7 8 9 10 11 12 |
... try: # get the exception from a done task result = task.exception() except asyncio.CancelledError: # confirm the target is done if task.done(): # handle cancellation of task # check if the current task has been requested to cancel if asyncio.current_task().cancelling() > 0: # propagate the exception up normally raise |
Practice #3: Assume a Task Will Be Canceled From The Inside
It is reasonable to expect that a given custom task may be canceled at any time.
Therefore, we should consider any cleanup to perform when the task is canceled.
This might involve:
- Logging
- Canceling subtasks manually
- Closing resources manually
- etc…
The intuition might be to wrap the body of the custom task with a try-except and handle the CancelledError.
The coroutine then has a chance to clean up or even deny the request by suppressing the exception with a try …, … except CancelledError … finally block.
— Coroutines and Tasks
For example:
1 2 3 4 5 6 7 |
... # custom coroutine task async def task(): try: # body... except asyncio.CancelledError # clean up |
This is not recommended
The reason is that the task will consume the CancelledError.
This means that other tasks and asyncio infrastructure that may try to cancel the task and hope/expect that it is canceled may be misled.
The asyncio components that enable structured concurrency, like asyncio.TaskGroup and asyncio.timeout(), are implemented using cancellation internally and might misbehave if a coroutine swallows asyncio.CancelledError.
— Coroutines and Tasks
Instead, it is a better practice to wrap the body of the custom task with a try-finally block.
For example:
1 2 3 4 5 6 |
# custom coroutine task async def task(): try: # body... finally # clean up |
This will ensure that the final activity is always performed, regardless of cancellation or fault.
It also allows CancelledError to be propagated up normally.
This means that if the task is canceled, it will still have the state “cancelled”, e.g. the cancelled() method will return True.
It also allows tasks at the scope above the canceled tasks to also consider and respond to the cancellation event (if needed).
Practice #4: Provide A “message” Argument To cancel()
If a task is manually requested to cancel, we will call the cancel() method.
This method takes a “message” argument.
For example:
1 2 3 |
... # request a task to cancel task.cancel('Result no longer needed') |
Always provide a value to the cancel() method.
The message must explain why the request was made and will probably be specific to the result expected from the target task, or the event that triggered the cancellation.
This message will then be populated into the CancelledError that is propagated and may be retrieved and logged.
Practice #5: Don’t Cancel a Task and Wait For It To Cancel
Calling the cancel() method does not cancel the task, it requests the task be canceled.
After learning this fact, the intuition may be to request the cancellation and then wait for the task to be canceled.
For example:
1 2 3 4 5 6 7 8 9 |
... # cancel the task task.cancel() # handle cancellation try: # wait for task to be done await task except asyncio.CancelledError: # task is cancelled |
The problem is, the task may perform many activities when canceled or when stopped generally.
For example, the body of the task may have a try-finally block and may involve much logging, canceling subtasks, and closing resources.
This could all take a long time.
In turn, this will suspend the current task until all of that is done.
Therefore, if there are activities to perform once the task is canceled, they can be added as done callback functions.
This can be achieved via the Task.add_done_callback() method.
These functions will be executed as soon as the task is done, or immediately if the task is already done.
For example:
1 2 3 4 5 |
... # cancel the task task.cancel() # do special stuff after it is done task.add_done_callback(special_cancel_tasks()) |
You can learn more about adding done callback functions to asyncio tasks in the tutorial:
Sometimes we really need to await the cancellation before resuming the current task.
This can be achieved by handling the CancelledError in a safe manner, e.g. ensuring we do not consume requests for the current task to also cancel.
For example:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
... # cancel the task task.cancel() # handle cancellation try: # wait for task to be done await task except asyncio.CancelledError: # confirm the target is done if task.done(): # handle cancellation of task # check if the current task has been requested to cancel if asyncio.current_task().cancelling() > 0: # propagate the exception up normally raise |
Recall, the task itself may be shielded or may consume the CancelledError. This means that although you may await for the task to be done, it may or may not have a status of canceled.
Practice #6: Don’t Assume Tasks Will Actually Cancel
We can request a task to be canceled via a call to the cancel() method.
If this method returns True, we may assume that we’re done. The task will be canceled.
This is not the case.
Therefore, unlike Future.cancel(), Task.cancel() does not guarantee that the Task will be cancelled
— Coroutines and Tasks
A task may consume the CancelledError.
- The task may decide to continue running and ignore the cancellation.
- The task may choose to clean up ready for cancellation and take a long time.
- The task may try and cancel and fail with an unexpected error.
Additionally, the task may be shielded.
This means that it will look like the request for cancellation was successful and that the task will be canceled, but the shield will consume the request.
This means that the inner-wrapped task will never see the request for cancellation.
Therefore, writing code that assumes a task was canceled via the cancelled() method can be problematic, e..g assuming it has a canceled status.
For example:
1 2 3 4 5 6 7 8 9 10 |
... # cancel the task task.cancel() try: # wait for task to be done await task except asyncio.CancelledError: # confirm the target task was canceled if task.cancelled(): # ... |
Instead, it may be safer to check that a canceled task is done, not canceled, such as via the done() method.
For example:
1 2 3 4 5 6 7 8 9 10 |
... # cancel the task task.cancel() try: # wait for task to be done await task except asyncio.CancelledError: # confirm the target task is now done if task.done(): # ... |
There is a good discussion of this pattern and patterns like it here:
- Safe synchronous cancellation in asyncio
- Asyncio.cancel() a cancellation utility as a coroutine [This time with feeling]
The pattern advocated in the discussion is to expect a CancelledError and raise a RuntimeError if one is not observed.
For example:
1 2 3 4 5 6 7 8 9 10 11 |
async def cancel_and_wait(task, msg=None): task.cancel(msg) try: await task except asyncio.CancelledError: if asyncio.current_task().cancelling() == 0: raise else: return # this is the only non-exceptional return else: raise RuntimeError("Cancelled task did not end with an exception") |
I’m not sure I want this contract often.
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 the best practices for canceling asyncio tasks in Python.
Did I make a mistake? See a typo?
I’m a simple humble human. Correct me, please!
Do you have any additional tips?
I’d love to hear about them!
Do you have any questions?
Ask your questions in the comments below and I will do my best to answer.
Photo by Nostra Damot on Unsplash
Amnon Harel says
What about sleeping tasks?
Do you need something like in https://stackoverflow.com/questions/37209864/interrupt-all-asyncio-sleep-currently-executing to cancel them without waiting for their (possible very long) sleep periods to be over?
Jason Brownlee says
Great question.
No, an asyncio.sleep() will be cancelled directly. The cancelation will propagate down.