Repository navigation
Safe synchronous cancellation in asyncio #103486
Description
Feature or enhancement
I propose adding a function to asyncio to cancel a task and then safely wait for the cancellation to complete.
The main difficulty with this is deciding whether to swallow or re-raise the CancelledError once we catch one. It we call the task that we're waiting on "dependency", and the task doing the waiting "controller", then there are two distinct possibilities:
- The "dependency" has finished its cleanup, passed its own
CancelledErrorall the way up its own stack, and then further up through theawaitin the "controller". In this case, theCancelledErrorshould be swallowed, and the "controller" can continue its work normally. - The "controller" itself was cancelled, while being on the
await. In this case, theCancelledErroris the signal to cancel the "controller" itself, and should be either re-raised further up, or its swallowing should be accompanied by a call touncancel.
The documentation for asyncio.Task.cancel, in fact, does not make this decision correctly, and thus would be a bug. Copying the example, and changing the names to match this issue's terminology:
async def dependency():
print('dependency(): start')
try:
await asyncio.sleep(3600) # a long or infinite loop
except asyncio.CancelledError:
print('dependency(): cancel')
raise
finally:
print('dependency(): cleanup')
async def controller():
dependency_task = asyncio.create_task(dependency())
await asyncio.sleep(1)
dependency_task.cancel()
try:
await dependency_task # controller itself may be cancelled at this moment
except asyncio.CancelledError:
print("controller(): dependency is cancelled now")
# CancelledError swallowed unconditionallyIf I'm not missing anything, the correct procedure would look like this:
async def controller():
dependency_task = asyncio.create_task(dependency())
await asyncio.sleep(1)
dependency_task.cancel()
try:
await dependency_task
except asyncio.CancelledError:
print("controller(): dependency is cancelled now")
if asyncio.current_task().cancelling() > 0:
raiseThus, I propose to make these changes:
-
Introduce a function to
asyncioorTask: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")
Having a specialized function would reduce the possibility of someone making this mistake in their code (like the author of the example probably did :) ), and allow the implementation to be changed or improved in the future. One such possible enhancement, for example, could be adding a
repeatparameter to instruct the function, in case the task uncancels itself, to keep cancelling it again in a loop. -
In the documentation for
asyncio, add a warning for this kind of mistake, in the "Task Cancellation" section or in the description ofasyncio.Task.cancel. -
Change the code example for
asyncio.Task.cancelto account for cancellation ofmain. I know that, in this specific snippet, it is impossible formainitself to be cancelled; but a developer unsuspecting of this issue may copy this example into a situation where the controller is indeed cancellable, and end up with a bug in their code.
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Activity
gvanrossum commentedon Apr 13, 2023
This feels very similar to gh-102847, which was just closed. Since that has much more discussion we should probably keep that one open instead?
achimnol commentedon Apr 14, 2023
I think this issue shares some contexts with gh-102847, but targets more specific use case: safe synchronous cancellation. Could we reopen it, then it may be better to collect the discussion there. I'd love to see some improvements on the "canceller" side APIs and ergonomics as the OP says.
gvanrossum commentedon Apr 14, 2023
I don't think that adding a new API is going to help much. Realistic use cases of cancellation (e.g. as used in task groups and the timeout context manager) wouldn't be able to use it. Most use cases are better off using something like timeout anyway.
You may have made a case for updating the example, but I resent the suggestion that the author of the example "probably made a mistake". As you mention, in the example there is no way
main()itself can be cancelled. But if you submit a PR that updates the example and explains (in not too many words) why thecancelling()check is needed it will probably be accepted. Alternatively, you could update the docs forcancelling()to include the improved example, and just plant a link there in the text accompanying the basiccancel()example. If you go that way, thecancel_me()function should probably be reduced to justawait sleep(3600), since the rest of the code is just there to demonstrate the execution flow through a coroutine that catchesCancelledError. (That's also true formain()BTW -- so it's probably better to update thecancelling()docs than thecancel()docs.)evgeny-osipenko commentedon Apr 15, 2023
Let me describe the context where I encountered this question.
I am building a system composed of multiple services that run in parallel in separate Python instances. To provide synchronization between the services where necessary, I use a shared database with a "locks" table, where the lock's name field, constructed from a resource's global id, is covered with a unique index. When a service takes a lock, it tries to create a row in this table, and to release the lock, the row is deleted.
In addition, each lock is equipped with a watchdog timer, so that if a service bugs out, crashes or "forgets" to unlock the resource in any other way, the lock would time out by itself. When a service genuinely needs to hold the lock for a long time, however, it can periodically reset the watchdog, thus preventing it from timing out for as long as the service is actually functional.
And so, what I wanted to have, is a context manager that takes care of both creating/removing the row and resetting the watchdog timer, by spawning an asyncio task for the duration of the "managed" code. An important caveat is that the "resetter" task must be stopped and joined before the row is removed; because, if the "resetter" discovers that the row has been removed and there is nothing to update anymore, it's treated as if the lock has timed out, the resource may already be in the middle of another process, and it's no longer safe to continue, so it raises an exception and interrupts the parent task.
In the end, though, I implemented this behavior by constructing a TaskGroup from within the lock manager, calling its
__aenter__and__aexit__manually, and putting the "resetter" task under its control:So, in the end, it might indeed be better to just add a note to the
cancelexample saying stuff like "checkcancellingbefore swallowingCancelledError" and "actually, just use aTaskGroup, it already has all that covered for you".achimnol commentedon May 1, 2023
The same suggestion from another person: https://discuss.python.org/t/asyncio-cancel-a-cancellation-utility-as-a-coroutine-this-time-with-feeling/26304
fried commentedon May 1, 2023
My talk at pycon.us 2022 https://youtu.be/XW7yv6HuWTE brought up this as an issue I see a lot at Meta. People screwing up cancelling tasks. I even wrote my own utility into the later: https://github.com/facebookincubator/later/blob/main/later/task.py#L59
Though looking at my implementation you can't cancel it if the task its trying to cancel won't finish :P So yeah this is a work in progress. Would be nice to have this in the stdlib. And I can't just tell developers to use taskgroups especially if they are interfacing with 3rd party code.
gvanrossum commentedon May 8, 2023
I suspect this will need a PEP, since so many people have broken their heads over this (even @fried admits his solution isn't perfect. :-) See https://discuss.python.org/t/asyncio-cancel-a-cancellation-utility-as-a-coroutine-this-time-with-feeling/26304/3 (I'm not sure where we should discuss this -- here on GitHub, or there on Discourse.)
xitop commentedon Jan 15, 2024
Speaking from my experience (yes, it's limited), this is an issue that should be solved while dealing with the whole task life-cycle, i.e. on a level above the asyncio, not in the asyncio.
Very closely related to cancellation are other problems I've encountered in my work: task monitoring (essential tasks may not exit or else let's shut down the whole app) and the shutdown management itself (task cancelling order, cleanup actions, etc). I have solved it "good enough" for my projects, i.e. far from being suitable for general use, but that work is the reason I'm now quite sure that a fully fledged tool for managing task and taking care of all the details, edge-cases etc. would create a complete asyncio add-on library and this particular issue belongs there.
arthur-tacca commentedon Mar 3, 2024
The root problem here that
await taskcould raise either that task's exception or the current task's own one. But you're only calling it to wait till the task is done – you can check the result later by looking attask.cancelled(). In fact you probably don't even care if it ends that way (rather than e.g. catchingCancelledErrorand returning).So what you all really need is a way to just wait for the task to finish. Something like
await task.wait_done(). Then your synchronous cancellation is two steps:await task.wait_done()would never raise regardless of howtaskfinished, but (like anyawait) would raiseCancelledErrorif the current task is cancelled. This is safe even if the task is already cancelled or already done.await task.wait_done()would also be a lot more generally useful than something tailored to cancellation.For the record, I don't have any need for this. This is just some drive-by API design 😊
arthur-tacca commentedon Mar 18, 2024
Isn't the
ifcondition the wrong way round? Ifasyncio.current_task().cancelling() == 0then that means the outer task is NOT being cancelled, so the cancellation is from the inner task (await task) so you would want to just return in that case.[edit: there was a second half to this comment that had a stupid idea which I have now deleted]
rrevans commentedon Dec 22, 2024
I think this would be the same as
asyncio.wait:After that, it's guaranteed both
taskis done and the current task was not cancelled during the wait. Then the outcome can be checked withTask.cancelled(),Task.result(), orTask.exception().arthur-tacca commentedon Dec 22, 2024
Very interesting, I think you're right. I had assumed that
asyncio.wait()would re-raise whatever exception (includingasyncio.CancelledError) had been raised from the task. In fairness the documentation forasyncio.wait()isn't explicit about what happens in that case; maybe all that's needed is to update those docs.cbornet commentedon Dec 22, 2024
So it seems a safer example to the one in the cancel() doc would be:
Does that look correct ?
rrevans commentedon Dec 23, 2024
Yes that looks correct and more robust against
mainbeing cancelled.As to what to do about the docs: they should probably be enhanced, but I'm not sure it makes sense to dump all the complexity into the first sample? I'm also receptive to the argument
maincan't be cancelled in the sample code.Maybe there should be some note after the existing sample that the plain
awaitraisingCancelledErroris ambiguous when the waiter itself is subject to being cancelled? Thenasyncio.waitcan be introduced as a possible solution?That explanatory text could also make mention of
TaskGroupas a possible alternative. (But they're quite different: the snippet above can itself be cancelled leaving the task still running whileTaskGroupcontext manager exit would always cancel at that point and wait until the task actually finishes deferring any cancellation of the waiter.)VietThan commentedon Feb 20, 2025
Based on @cbornet comment, I've made the following implementation, is this overkill?
LukasLendvorsky commentedon Mar 6, 2025
I propose better implementation of cancel_and_wait:
Original one has following flaws:
loucadufault commentedon Mar 25, 2025
Is it safe to assume
asyncio.wait_forbehaves the same asasyncio.waitwith respect to not re-raising theCancelledErrorof the awaited Task?Edit: Answer: yes.
wait_forcallstimeouts.timeoutwhich documents this behaviour:cpython/Lib/asyncio/timeouts.py
Lines 155 to 157 in 8ada7a9
So the example in the docs, if one wishes to allow some time for the Task to cancel before moving on:
The shortcoming is the code does not distinguish whether the wrapped coroutine propagated the
CancelledErrorthat was thrown into it from the firstcancel, or only from the secondcancelafter timing out.achimnol commentedon Oct 4, 2025
I think this is the way to go.
Here are some experimentation and test cases with it: https://github.com/achimnol/aiotools/pull/58/files#diff-ecd5619244b1590bc0c08147d441a754ac7e0d70cac95383bd59a01e5c9127ff
Without the

cancelling_expectedcounting, there are some failing test cases:achimnol commentedon Oct 10, 2025
I found another piece of improvement.
The current stdlib documentation says:
So when a user-defined task does:
the
cancel_and_wait()should be updated to handle this case:Add plan for fixing asyncio cancellation handling