Asyncio Cancel Task Cancellation (Uncancel)
You can cancel a request to cancel a task by developing asyncio tasks that are robust to cancellation.
This requires tasks that consume a raised CancelledError on cancellation and reset their own count of cancellation requests.
In this tutorial, you will discover how to cancel an asyncio task cancellation (uncancel a task).
Let's get started.
What is Asyncio 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 return 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:
...
# cancel a task
if task.cancel():
# request successful
The next time that the target task that was cancelled resumes, a CancelledError exception will be raised.
This exception will bubble up to the top level of the task and cause it to stop running.
You can learn more about the mechanics of cancellation a task in the tutorial:
Next, let's consider the need to cancel a request to cancel a task.
Need to Cancel Task Cancellation
We may cancel a task, then later decide that we do not want to cancel it.
We want to cancel the task cancellation, or put it another way, we want to uncancel a task.
This may be for many reasons, such as the condition that triggered cancellation changed.
Canceling a cancellation request will allow the task to have the external state of not having been canceled.
It will also have the internal state of (nominally) continuing execution of the task body.
How can we cancel a task cancellation request?
Canceling Task Cancellation is Not Shielding
Canceling a task cancellation request is different from shielding a task from cancellation.
Recall that we can shield a task from cancellation by wrapping it in another task that consumes the CancelledError exception.
This can be achieved via the asyncio.shield() call.
For example:
...
# shield task
shielded = asyncio.shield(task())
# cancel shielded task
shielded.cancel()
You can learn more about shielding tasks from cancellation in the tutorial:
Instead, we are concerned with the inner task that we know will receive the cancellation request directly.
How to Cancel Task Cancellation
A task is cancelled if:
- The cancel() method is called on the asyncio.Task object.
- The cancelling() method now returns an integer of 1 or greater.
- An asyncio.CancelledError exception is raised within the task body.
- The asyncio.CancelledError exception bubbles up causing the task to be done.
- The cancelled() method returns True, once the task is done.
Given this process of how cancellation occurs, there are perhaps two considerations when canceling task cancellation, they are:
- Removing the request to cancel so that cancelling() returns 0.
- Handling or ignoring the asyncio.CancelledError exception.
Let's consider both in turn.
Removing the Request to Cancel with Task.uncancel()
An asyncio.Task keeps track of the number of times it is requested to cancel.
This feature was added in Python 3.11.
Added cancelling() and uncancel() methods to Task. These are primarily intended for internal use, notably by TaskGroup.
-- What's New In Python 3.11
Each call to the Task.cancel() method increments the count.
The Task.cancelling() method returns the count.
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
The Task.uncancel() decrements the count.
Decrement the count of cancellation requests to this Task.
-- Coroutines and Tasks
- Task.cancel(): Increment cancel requests.
- Task.cancelling(): Return cancel requests.
- Task.uncancel(): Decrement cancel requests.
An intuition is that a request to cancel is acted upon by the task the next time it resumes only if the cancelling() method returns an integer of 1 or more.
This is not true.
For example, calling cancel() followed by uncancel() on a task and then awaiting the task will still result in the task internally raising an asyncio.CancelledError and being cancelling.
For example:
...
# cancel the task
task.cancel()
# decide against the cancellation
task.uncancel()
# await the task
await task # raises a asyncio.CancelledError
Therefore, it is a good practice to decrement the requests for cancellation with a call to the uncancel() method if we no longer want the task to cancel, but it is not sufficient to cancel the cancellation.
If end-user code is, for some reason, suppressing cancellation by catching CancelledError, it needs to call this method to remove the cancellation state.
-- Coroutines and Tasks
Handle and Ignore CancelledError
Cancelling a task cancellation request must involve the handing of the CancelledError raised in the target task.
This may be achieved by wrapping the task in a task that consumes the CancelledError, such as via asyncio.shield(), but this may limit direct access to the underlying task so that we can call uncancel() and reset the cancellation request count.
Alternatively, we can handle the CancelledError directly within the body of the task.
At a high level, this might look like the following:
# custom coroutine
async def work():
# handle cancellation
try:
# do work
# ...
except asyncio.CancelledError:
# ignore
pass
More robustly, each point of suspension in a task would require such wrapping, as those are the points where the task may be suspended, and then resume with a raised asyncio.CancelledError.
The consumption of the CancelledError exceptions could be conditional, enabled or disabled internally or externally using a variable attribute.
Additionally, the request for cancellation should also be scrubbed from the task.
This can be achieved by calling the uncancel() method on the task externally.
Internally, this can be achieved by calling asyncio.current_task() to get the current asyncio.Task object, then calling the uncancel() method.
For example:
...
# reset cancelling counter
asyncio.current_task().uncancel()
Both consume the asyncio.CancelledError and resetting the number of requested cancellations will allow the asyncio.Task to look, feel, and act like an uncancelled task.
Note that consuming asyncio.CancelledError exceptions is discouraged.
See the tutorial:
Now that we know how to cancel a request to cancel a task, let's look at some worked examples.
Example of uncancel() Does Not Prevent a CancelledError Being Raised
We can explore an example of uncancel() not preventing an CancelledError exception being raised in the cancelled task.
In this example, we will define a task that takes a while to run. The task will be scheduled and given time to run. The task will then be canceled via a call to cancel(), then uncancelled via a call to uncancel(), then awaited.
The expectation is that the target task will be cancelled by raising a CancelledError exception, and that awaiting the target task will cause the CancelledError to bubble up to the top level and terminate the event loop.
Firstly, we can define the long-running task that sleeps for 5 seconds and reports a message.
# task that take a long time
async def work():
# sleep a long time
await asyncio.sleep(5)
# report a message
print('Task sleep completed normally')
Next, the main() coroutine can be defined that creates and schedules the work() task and allows it to begin executing.
It then cancels the task, reports the number of cancel requests and uncancelled the task, and again reports the number of cancel requests to confirm it is reset. The task is then awaited.
# main coroutine
async def main():
# create and schedule the task
task = asyncio.create_task(work())
# allow the task to run a moment
await asyncio.sleep(1)
# cancel the task
task.cancel()
print(f'Task Cancelling: {task.cancelling()}')
# decide against the cancellation
task.uncancel()
print(f'Task Cancelling: {task.cancelling()}')
# await the task
await task
Finally, the event loop is started.
...
# start the event loop
asyncio.run(main())
Tying this together, the complete example is listed below.
# SuperFastPython.com
# example of canceling a task cancellation within the task
import asyncio
# task that take a long time
async def work():
# sleep a long time
await asyncio.sleep(5)
# report a message
print('Task sleep completed normally')
# main coroutine
async def main():
# create and schedule the task
task = asyncio.create_task(work())
# allow the task to run a moment
await asyncio.sleep(1)
# cancel the task
task.cancel()
print(f'Task Cancelling: {task.cancelling()}')
# decide against the cancellation
task.uncancel()
print(f'Task Cancelling: {task.cancelling()}')
# await the task
await task
# start the event loop
asyncio.run(main())
Running the example first starts the event loop.
The main() coroutine runs and creates and schedules the work() task.
The main() coroutine suspends and allows the work() task to run.
The work() task runs and suspends on a call to sleep.
The main() coroutine resumes and cancels the work() task, then reports the number of cancellation requests.
This shows the one expected request.
The main() coroutine then uncancels the cancel request on the work() task and again reports the number of cancellation requests.
This shows zero requests, as expected, confirming the count has been reset.
The main() coroutine then suspends and awaits the work() coroutine.
The work() coroutine resumes and raises a CancelledError exception, canceling the task.
This bubbles up to main() and is not handled, causing the event loop to be terminated.
This highlights that calling uncancel() on a task is not sufficient to cancel a request for cancellation.
Task Cancelling: 1
Task Cancelling: 0
Traceback (most recent call last):
File "..", line 32, in <module>
asyncio.run(main())
File ".../asyncio/runners.py", line 190, in run
return runner.run(main)
^^^^^^^^^^^^^^^^
File ".../asyncio/runners.py", line 118, in run
return self._loop.run_until_complete(task)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File ".../asyncio/base_events.py", line 653, in run_until_complete
return future.result()
^^^^^^^^^^^^^^^
File ...", line 25, in main
await task
File "...", line 8, in work
await asyncio.sleep(5)
File ".../asyncio/tasks.py", line 639, in sleep
return await future
^^^^^^^^^^^^
asyncio.exceptions.CancelledError
Next, let's explore an example of actively canceling or suppressing a request to cancel a task.
Example of Cancelling Task Cancellation From Within The Task
We can explore a case of actively canceling a request to cancel an asyncio task.
This requires both consuming the raised CancelledError within the task and resetting the cancellation request count.
In this case, we will develop a contrived task that is robust to cancellation. Each time it is cancelled, it will attempt to retry the same task from scratch. When cancelled it will handle and consume the CancelledError and reset its own internal cancellation request count.
The work() coroutine below implements this.
# task that take a long time
async def work():
# ensure the task always gets done
while True:
try:
# sleep a long time
await asyncio.sleep(5)
# report a message
print('Task sleep completed normally')
# stop, we are done
break
except asyncio.CancelledError:
# report a message
print('Task as cancelled, ignore and trying again')
# reset cancelling counter
asyncio.current_task().uncancel()
This suggests that the lengths required to ensure a task is robust to cancellation, e.g. allowing restarts potentially with checkpoints. Onerous to say the least.
The main() coroutine will then create and schedule the task and allow it to begin executing.
...
# create and schedule the task
task = asyncio.create_task(work())
# allow the task to run a moment
await asyncio.sleep(1)
Next, the main() coroutine will cancel the task and await it, expecting it to raise a CancelledError, which we know it will not.
...
# cancel the task
task.cancel()
# await the task
try:
await task
except asyncio.CancelledError:
print('Main saw the task cancel')
Finally, the details of the task are reported.
...
# report details of the task
print(f'Task Cancelling: {task.cancelling()}')
print(f'Task Cancelled: {task.cancelled()}')
print(f'Task: {task}')
print('Main is done')
The complete main() coroutine is listed below.
# main coroutine
async def main():
# create and schedule the task
task = asyncio.create_task(work())
# allow the task to run a moment
await asyncio.sleep(1)
# cancel the task
task.cancel()
# await the task
try:
await task
except asyncio.CancelledError:
print('Main saw the task cancel')
# report details of the task
print(f'Task Cancelling: {task.cancelling()}')
print(f'Task Cancelled: {task.cancelled()}')
print(f'Task: {task}')
print('Main is done')
Tying this together, the complete example is listed below.
# SuperFastPython.com
# example of canceling a task cancellation within the task
import asyncio
# task that take a long time
async def work():
# ensure the task always gets done
while True:
try:
# sleep a long time
await asyncio.sleep(5)
# report a message
print('Task sleep completed normally')
# stop, we are done
break
except asyncio.CancelledError:
# report a message
print('Task as cancelled, ignore and trying again')
# reset cancelling counter
asyncio.current_task().uncancel()
# main coroutine
async def main():
# create and schedule the task
task = asyncio.create_task(work())
# allow the task to run a moment
await asyncio.sleep(1)
# cancel the task
task.cancel()
# await the task
try:
await task
except asyncio.CancelledError:
print('Main saw the task cancel')
# report details of the task
print(f'Task Cancelling: {task.cancelling()}')
print(f'Task Cancelled: {task.cancelled()}')
print(f'Task: {task}')
print('Main is done')
# start the event loop
asyncio.run(main())
Running the example first starts the event loop.
The main() coroutine runs and creates and schedules the work() task.
The main() coroutine suspends and allows the work() task to run.
The work() task runs and suspends on a call to sleep.
The main() coroutine resumes and cancels the work() task, then awaits the task to be cancelled.
The work() task raises a CancelledError, which is handled. A message is logged, and the task resets its cancel request count. It then restarts the task.
The work() task completes normally and reports a message, then terminates.
The main() coroutine resumes and does not handle a CancelledError, because it was not propagated up from the task.
The main() coroutine then reports the details of the task, highlighting that the cancel request count is zero and that task was not cancelled.
Report the task itself, we can see that it has the state "done" and not "cancelled".
This highlights how we might effectively uncancel a cancelled task from within the task, by making the task robust to requests for cancellation.
Task as cancelled, ignore and trying again
Task sleep completed normally
Task Cancelling: 0
Task Cancelled: False
Task: <Task finished name='Task-2' coro=<work() done, defined at ...> result=None>
Main is done
Takeaways
You now know how to cancel an asyncio task cancellation (uncancel a task).
If you enjoyed this tutorial, you will love my book: Python Asyncio Jump-Start. It covers everything you need to master the topic with hands-on examples and clear explanations.