Asyncio.cancel() a cancellation utility as a coroutine [This time with feeling]

I propose asyncio.cancel(task, *, msg=None) a novel utility for helping in cancelling tasks/futures.

Why? I have seen too much code that calls task.cancel() and assumes immediate cancellation. With no await of the cancelled task or even a callback (yuck)

The first thing one needs to do after calling cancel() on the task is to await the task. Which when its finished cancelling will result in a CancelledError being thrown.

We can see in the docs this pattern under Task.cancel

# https://docs.python.org/3/library/asyncio-task.html#asyncio.Task.cancel
task.cancel()
try:
    await task
except asyncio.CancelledError:
    print("...")

BUT… What if the await task took some time and something cancelled that task doing the cancelling? It is going to eat the CancelledError which goes against the contract of cancellation. So we need something a little more complicated to tell the difference between a cancellation from below and one coming from above.

All of this makes task.cancel() pretty non-trivial to use properly. if you need even a short set of steps to do it correctly then we should(imho) have a utility in asyncio that will take care of it for us. And it should be a coroutine so we can await on its completion.

I have an example of this later/task.py at main · facebookincubator/later · GitHub which is what I have had people use @ Meta to insure something is actually cancelled. I’m sure the Asyncio experts can figure a better way to do what I’m doing with shielding but you get the idea. I wrote some tests for that code also later/test_task.py at main · facebookincubator/later · GitHub

The questions come up what about tasks the don’t cancel, that’s “actively discouraged” in the docs. And to keep the implementation simple if you want to wait a limited amount of time you can wrap the call in a Timeout. Also with the new uncancel system i’m sure we could raise an exception if the task you try to cancel is ignoring cancellation.

You could also support the cancel of multiple tasks at one time, but that requires the ExceptionGroup which I didn’t have when I originally wrote later.cancel, it also complicates the code a little bit.

Also I could be completely off my rocker with this idea, but it has been helpful when handling the orderly shutdown of services at Meta without having to rely on asyncio.run to cancel all the outstanding tasks when ordering could be a concern.

2 Likes

I also just thought that one could examine current_task to see if we were the ones being cancelled instead of the task below being cancelled.

I think your argument exactly matches with the above GitHub issue.

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")

This seems to be a pretty much repeating pattern along with structured concurrency.
Personally I agree with you in that it would be better to have an instrinsic support for this pattern from the stdlib, but we need to persuade other core developers.

2 Likes

Interesting. I think we need to have someone champion a PEP about this topic so we can get sufficient review of the proposed design. (I can sponsor a PEP but I don’t think I have time to do the writing or coding or research.)

Some suggestions:

  • cancel_and_await() should return None once the task is cancelled, right? How to distinguish between that and “it was already done”? Maybe the answer is “if you need to know, check task.done() yourself”.
  • What if it raised some other exception? Probably it should re-raise that.
  • Hopefully we won’t need a way to cancel multiple tasks – you can just do
    async with asyncio.TaskGroup() as tg:
        tg.create_task(cancel_and_wait(t1))
        tg.create_task(cancel_and_wait(t2))
    
  • The latest hotness to distinguish between the awaited task exiting with a CancelledError and the awaiting task being cancelled is to use some combination of Task.uncancel() and/or Task.cancelling() – see taskgroups.py and timeouts.py for examples.
  • The best practice idiom should probably wrap everything in a timeout() call, but that’s up to the caller.

What did I miss?

1 Like

Well, based on the things that were mentioned above, I can open a PR, and maybe a corresponding issue.
From my point of view, it shouldn’t be too complicated and adding this as a pep sounds too much for me(tell me if I’m wrong).

try:
    async with asyncio.timeout(-1):
        await task
except TimeoutError:
     ...

Following the recommended design above, it could be implemented easily.

If the PR is going to be based on @fried’s later library, I would prefer it if @friend himself were to submit the PR – that way proper credit is received and the provenance of the code in the PR is not in question.

@tamas If you want to help, please go over the @fried’s code and see if you understand every step of it completely. Then explain the design here. Once we have a clear understanding we can see if we end up with consensus that that is the right design. Then we can start with the PR. (And let’s be clear that such a PR should also contain unit tests and docs.)

But until we have consensus about the design I don’t see much of a point in opening an issue or PR.

I personally find @fried’s code too complex to try to understand on my day off. E.g. why use shield()?

Moreover there’s the alternative proposal by @achimnol: Safe synchronous cancellation in asyncio · Issue #103486 · python/cpython · GitHub. It would be nice to compare and contrast with @fried’s code.

okay, I will try to understand the code, and maybe I can come up with something.

I feel like it’s really hard to understand fried’s code without asking him about the purposes of different features being implemented in his function. If he doesn’t want to help anymore or is not active, probably easier to build it from scratch without relying on his code.

We also should consider this design:

try:
    async with asyncio.timeout(-1):
        await task
except TimeoutError:
     ...