Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Implement asyncio.Task.__cancel_requested__ #31313

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 2 commits into from
Closed

Conversation

asvetlov
Copy link
Contributor

Add __cancel_requested__ property as discussed in #31270.

cancel() returns False is the task was previously cancelled already.

No test is failing during the cancel() behavior change.

@asvetlov
Copy link
Contributor Author

The design follows EdgeDB monkey-patch.

Speaking about public API, I can imagine an alternative design:

  1. No public properties are added, Task.cancelling() (along with Future.cancelling()) method is added. The boolean internal flag exists, sure -- but it is a private variable that is not exposed, except for __repr__ maybe.
  2. The method returns True if a cancellation was requested (__cancel_requested__ == True from initial implementation), False otherwise.
  3. The flag is reset just before raising CancelledError inside a task. The reset doesn't change TaskGroup implementation logic but can help with strange designed tasks that swallow CancelledError. Sure, good-written code should avoid such situations but the design is still valid and allowed. The reset before task execution maybe look a little awkward but I have no better idea. The reset after the first __step() call is even worse, the task can take several iterations with multiple awaits for graceful cancellation.
  4. TaskGroup has a single task.__cancel_requested__ = True assignment, it can be avoided easily by replacing if not self._parent_task.__cancel_requested__: with if not self._parent_cancel_requested and not self._parent_task.__cancel_requested__:

I believe the proposed design is cleaner; it doesn't allow .__cancel_requested__ setting outside of Task's implementation code. Resetting the flag before raising CancelledError allows handling cancellation ignorance by a task code.

What do you think?
I can prepare the alternative PR to compare if the idea seems viable.

Copy link
Member

@gvanrossum gvanrossum left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks -- my concern here is that __cancel_requested__ is a rather unconventional API design. I would rather name it _cancel_requested, which is in line with the other attributes, or if we want this to be a truly public API (not just something for private use by asyncio.TaskGroup) make it a pair of methods, cancel_requested() returning a bool and set_cancel_requested() taking a bool.

@gvanrossum
Copy link
Member

@asvetlov I apologize, I didn't see your large comment with the alternative design before my own comment. This stuff is excruciating to understand (for me anyways, since I didn't write EdgeDb's TaskGroup :-) so I have to experiment some more.

I will see if I can get the new TaskGroup to work with that, I will get back to you.

@gvanrossum
Copy link
Member

@asvetlov I cannot get this to work. Here's what I did.

(1) Implement Task.cancelling() as follows:

diff --git a/Lib/asyncio/tasks.py b/Lib/asyncio/tasks.py
index 2bee5c050d..6f726b2ff8 100644
--- a/Lib/asyncio/tasks.py
+++ b/Lib/asyncio/tasks.py
@@ -105,6 +105,7 @@ def __init__(self, coro, *, loop=None, name=None):
         else:
             self._name = str(name)
 
+        self._cancelling = False
         self._must_cancel = False
         self._fut_waiter = None
         self._coro = coro
@@ -178,6 +179,9 @@ def print_stack(self, *, limit=None, file=None):
         """
         return base_tasks._task_print_stack(self, limit, file)
 
+    def cancelling(self):
+        return self._cancelling
+
     def cancel(self, msg=None):
         """Request that this task cancel itself.
 
@@ -201,6 +205,9 @@ def cancel(self, msg=None):
         self._log_traceback = False
         if self.done():
             return False
+        if self._cancelling:
+            return False
+        self._cancelling = True
         if self._fut_waiter is not None:
             if self._fut_waiter.cancel(msg=msg):
                 # Leave self._fut_waiter; it may be a Task that
@@ -231,6 +238,7 @@ def __step(self, exc=None):
                 # don't have `__iter__` and `__next__` methods.
                 result = coro.send(None)
             else:
+                self._cancelling = False
                 result = coro.throw(exc)
         except StopIteration as exc:
             if self._must_cancel:

(2) In taskgroups.py: Disable task patching in taskgroups.py; delete the assignment (of False) to __cancel_requested__; replace the check for __cancel_requested__ per your suggestion (sort of).

diff --git a/Lib/asyncio/taskgroups.py b/Lib/asyncio/taskgroups.py
index 4f946eb0e4..721115a2d8 100644
--- a/Lib/asyncio/taskgroups.py
+++ b/Lib/asyncio/taskgroups.py
@@ -78,7 +78,6 @@ async def __aenter__(self):
         if self._parent_task is None:
             raise RuntimeError(
                 f'TaskGroup {self!r} cannot determine the parent task')
-        self._patch_task(self._parent_task)
 
         return self
 
@@ -95,7 +94,7 @@ async def __aexit__(self, et, exc, tb):
             if self._parent_cancel_requested:
                 # Only if we did request task to cancel ourselves
                 # we mark it as no longer cancelled.
-                self._parent_task.__cancel_requested__ = False
+                pass
             else:
                 propagate_cancellation_error = et
 
@@ -244,7 +243,7 @@ def _on_task_done(self, task):
             return
 
         self._abort()
-        if not self._parent_task.__cancel_requested__:
+        if not self._parent_cancel_requested and not self._parent_task.cancelling():
             # If parent task *is not* being cancelled, it means that we want
             # to manually cancel it to abort whatever is being run right now
             # in the TaskGroup.  But we want to mark parent task as

(3) Disable the C accellerator at the top of test_taskgroups.py:

diff --git a/Lib/test/test_asyncio/test_taskgroups.py b/Lib/test/test_asyncio/test_taskgroups.py
index 8a01985c06..2d909dad80 100644
--- a/Lib/test/test_asyncio/test_taskgroups.py
+++ b/Lib/test/test_asyncio/test_taskgroups.py
@@ -16,6 +16,9 @@
 # limitations under the License.
 #
 
+import sys
+assert "_asyncio" not in sys.modules
+sys.modules["_asyncio"] = None  # Use the pure Python version
 
 import asyncio

Now when I run

./python.exe -m test test_asyncio.test_taskgroups -v

I get failures in test_taskgroup_{15,16}. In both cases I get an unexpected ExceptionGroup wrapping ZeroDivisionError, where a CancelledError is expected.

Can you repro this? Can you explain or fix it?

@gvanrossum
Copy link
Member

I think I understand the cause of the failure now (not sure this will lead to a fix yet).

Test 15 has a try/except in the body of the async with TaskGroup() block, where in the try block it awaits a long sleep (which will be cancelled from the outside after 0.1 second), and the except catches CancelledError and then sleeps some more (0.5s) before re-raising it. During this second sleep a subtask crashes (0.3s into the test).

The desired behavior here is that the sleep in the except block is not cancelled when the subtask crashes, and the sleep(0.5) is allowed to finish after which things reach __aexit__ with CancelledError.

(Test 16 is a variation where there's another task wrapping the whole business -- the outermost task is cancelled, and this is expected to be propagated into the nested task.)

Because the initial cancellation of the parent task happens from the outside, it is essential that its __cancel_requested__ flag remains set while the sleep(0.5) runs, so any flag that is reset when the coroutine is entered is not good enough, and a flag that is set only by the code in TaskGroup won't work either. We really need to remember that the parent task was cancelled across potentially many resumptions and suspensions of its coroutine. (Coincidentally this is also the thing that the Trio folks brought up in my original PR -- Yury solved it with this __cancel_requested__ behavior.)

I did find that none of the tests fail if I replace the line self._parent_task.__cancel_requested__ = False with pass, so perhaps we do not actually need the ability to reset this flag; or maybe there's a missing test. However, the check if self._parent_cancel_requested is required; if I just always set propagate_cancellation_error = et once we get to that point, some tests fail.

That's as far as I got. If we're going to design a public API for this, it would have to be a flag on tasks that is set when the task is cancelled, and which then stays set for the duration. We could add an API to reset the flag, which could be used by a task that catches cancellation and decides to ignore it (rather than just taking its time to clean up, like tests 15 and 16). Until the flag is cleared, further efforts to cancel the task are ignored.

@gvanrossum
Copy link
Member

@asvetlov I would like to make progress on this. Mind if I just incorporate my version into the TaskGroup PR? My proposal is the following:

  • There's an internal flag, _cancelling, that has the same meaning as what you implemented (and EdgeDb's TaskGroup uses) as __cancel_requested__, but the public API is two methods, t.cancelling() that is checked and set by t.cancel(), and t.uncancel() that resets it.
  • The internal flag is inaccessible in the _asyncio.Task implementation, you must use the public API.
  • There is no such API or flag for Future (I don't see the need).

(@agronholm I think this may solve your problem too.)

gvanrossum added a commit to gvanrossum/cpython that referenced this pull request Feb 14, 2022
This means we no longer have to monkey-patch the parent task.

It does introduce new semantics for task cancellation:
When a task is cancelled, further attempts to cancel it
have *no* effect unless the task calls self.uncancel().

Borrowed from pythonGH-31313 by @asvetlov.
@gvanrossum
Copy link
Member

@asvetlov I am closing this. I took this as a starting point for a public API that defines .cancelling() and .uncancel() methods (on Tasks only) in GH-31270.

Thank you very much for this -- without this I wouldn't have know where to start!

@asvetlov
Copy link
Contributor Author

Thanks, @gvanrossum
I love .cancelling() / .uncancel(). The behavior is very clear!

@zware zware deleted the task-cancel branch May 6, 2022 14:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants