-
-
Notifications
You must be signed in to change notification settings - Fork 31.9k
gh-90908: Document asyncio.Task.cancelling() and asyncio.Task.uncancel() #95253
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
Changes from all commits
708cb27
0203e01
4a6a2fe
ad49eb0
4c56381
13515f3
f0a215d
f3bcc6f
26cf287
9296af0
4114a79
50850de
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -294,11 +294,13 @@ perform clean-up logic. In case :exc:`asyncio.CancelledError` | |
is explicitly caught, it should generally be propagated when | ||
clean-up is complete. Most code can safely ignore :exc:`asyncio.CancelledError`. | ||
|
||
Important asyncio components, like :class:`asyncio.TaskGroup` and the | ||
:func:`asyncio.timeout` context manager, are implemented using cancellation | ||
internally and might misbehave if a coroutine swallows | ||
:exc:`asyncio.CancelledError`. | ||
The asyncio components that enable structured concurrency, like | ||
:class:`asyncio.TaskGroup` and :func:`asyncio.timeout`, | ||
are implemented using cancellation internally and might misbehave if | ||
a coroutine swallows :exc:`asyncio.CancelledError`. Similarly, user code | ||
should not call :meth:`uncancel <asyncio.Task.uncancel>`. | ||
|
||
.. _taskgroups: | ||
|
||
Task Groups | ||
=========== | ||
|
@@ -1003,76 +1005,6 @@ Task Object | |
Deprecation warning is emitted if *loop* is not specified | ||
and there is no running event loop. | ||
|
||
.. method:: cancel(msg=None) | ||
|
||
Request the Task to be cancelled. | ||
|
||
This arranges for a :exc:`CancelledError` exception to be thrown | ||
into the wrapped coroutine on the next cycle of the event loop. | ||
|
||
The coroutine then has a chance to clean up or even deny the | ||
request by suppressing the exception with a :keyword:`try` ... | ||
... ``except CancelledError`` ... :keyword:`finally` block. | ||
Therefore, unlike :meth:`Future.cancel`, :meth:`Task.cancel` does | ||
not guarantee that the Task will be cancelled, although | ||
suppressing cancellation completely is not common and is actively | ||
discouraged. | ||
|
||
.. versionchanged:: 3.9 | ||
Added the *msg* parameter. | ||
|
||
.. deprecated-removed:: 3.11 3.14 | ||
*msg* parameter is ambiguous when multiple :meth:`cancel` | ||
are called with different cancellation messages. | ||
The argument will be removed. | ||
|
||
.. _asyncio_example_task_cancel: | ||
|
||
The following example illustrates how coroutines can intercept | ||
the cancellation request:: | ||
|
||
async def cancel_me(): | ||
print('cancel_me(): before sleep') | ||
|
||
try: | ||
# Wait for 1 hour | ||
await asyncio.sleep(3600) | ||
except asyncio.CancelledError: | ||
print('cancel_me(): cancel sleep') | ||
raise | ||
finally: | ||
print('cancel_me(): after sleep') | ||
|
||
async def main(): | ||
# Create a "cancel_me" Task | ||
task = asyncio.create_task(cancel_me()) | ||
|
||
# Wait for 1 second | ||
await asyncio.sleep(1) | ||
|
||
task.cancel() | ||
try: | ||
await task | ||
except asyncio.CancelledError: | ||
print("main(): cancel_me is cancelled now") | ||
|
||
asyncio.run(main()) | ||
|
||
# Expected output: | ||
# | ||
# cancel_me(): before sleep | ||
# cancel_me(): cancel sleep | ||
# cancel_me(): after sleep | ||
# main(): cancel_me is cancelled now | ||
|
||
.. method:: cancelled() | ||
|
||
Return ``True`` if the Task is *cancelled*. | ||
|
||
The Task is *cancelled* when the cancellation was requested with | ||
:meth:`cancel` and the wrapped coroutine propagated the | ||
:exc:`CancelledError` exception thrown into it. | ||
|
||
.. method:: done() | ||
|
||
Return ``True`` if the Task is *done*. | ||
|
@@ -1186,3 +1118,125 @@ Task Object | |
in the :func:`repr` output of a task object. | ||
|
||
.. versionadded:: 3.8 | ||
|
||
.. method:: cancel(msg=None) | ||
|
||
Request the Task to be cancelled. | ||
|
||
This arranges for a :exc:`CancelledError` exception to be thrown | ||
into the wrapped coroutine on the next cycle of the event loop. | ||
|
||
The coroutine then has a chance to clean up or even deny the | ||
request by suppressing the exception with a :keyword:`try` ... | ||
... ``except CancelledError`` ... :keyword:`finally` block. | ||
Therefore, unlike :meth:`Future.cancel`, :meth:`Task.cancel` does | ||
not guarantee that the Task will be cancelled, although | ||
suppressing cancellation completely is not common and is actively | ||
discouraged. | ||
|
||
.. versionchanged:: 3.9 | ||
Added the *msg* parameter. | ||
|
||
.. deprecated-removed:: 3.11 3.14 | ||
*msg* parameter is ambiguous when multiple :meth:`cancel` | ||
are called with different cancellation messages. | ||
The argument will be removed. | ||
|
||
.. _asyncio_example_task_cancel: | ||
|
||
The following example illustrates how coroutines can intercept | ||
the cancellation request:: | ||
|
||
async def cancel_me(): | ||
print('cancel_me(): before sleep') | ||
|
||
try: | ||
# Wait for 1 hour | ||
await asyncio.sleep(3600) | ||
except asyncio.CancelledError: | ||
print('cancel_me(): cancel sleep') | ||
raise | ||
finally: | ||
print('cancel_me(): after sleep') | ||
|
||
async def main(): | ||
# Create a "cancel_me" Task | ||
task = asyncio.create_task(cancel_me()) | ||
|
||
# Wait for 1 second | ||
await asyncio.sleep(1) | ||
|
||
task.cancel() | ||
try: | ||
await task | ||
except asyncio.CancelledError: | ||
print("main(): cancel_me is cancelled now") | ||
|
||
asyncio.run(main()) | ||
|
||
# Expected output: | ||
# | ||
# cancel_me(): before sleep | ||
# cancel_me(): cancel sleep | ||
# cancel_me(): after sleep | ||
# main(): cancel_me is cancelled now | ||
|
||
.. method:: cancelled() | ||
|
||
Return ``True`` if the Task is *cancelled*. | ||
|
||
The Task is *cancelled* when the cancellation was requested with | ||
:meth:`cancel` and the wrapped coroutine propagated the | ||
:exc:`CancelledError` exception thrown into it. | ||
|
||
.. method:: uncancel() | ||
|
||
Decrement the count of cancellation requests to this Task. | ||
|
||
Returns the remaining number of cancellation requests. | ||
|
||
Note that once execution of a cancelled task completed, further | ||
calls to :meth:`uncancel` are ineffective. | ||
|
||
.. versionadded:: 3.11 | ||
|
||
This method is used by asyncio's internals and isn't expected to be | ||
used by end-user code. In particular, if a Task gets successfully | ||
ambv marked this conversation as resolved.
Show resolved
Hide resolved
|
||
uncancelled, this allows for elements of structured concurrency like | ||
:ref:`taskgroups` and :func:`asyncio.timeout` to continue running, | ||
isolating cancellation to the respective structured block. | ||
For example:: | ||
|
||
async def make_request_with_timeout(): | ||
try: | ||
async with asyncio.timeout(1): | ||
# Structured block affected by the timeout: | ||
await make_request() | ||
await make_another_request() | ||
except TimeoutError: | ||
log("There was a timeout") | ||
# Outer code not affected by the timeout: | ||
await unrelated_code() | ||
|
||
While the block with ``make_request()`` and ``make_another_request()`` | ||
might get cancelled due to the timeout, ``unrelated_code()`` should | ||
continue running even in case of the timeout. This is implemented | ||
with :meth:`uncancel`. :class:`TaskGroup` context managers use | ||
:func:`uncancel` in a similar fashion. | ||
Comment on lines
+1221
to
+1225
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems to imply that without So why does I think this means that to make the example meaningful, you'd have to nest two timeout blocks whose timers go off simultaneously. Then the inner one will raise It is not a coincidence that
|
||
|
||
.. method:: cancelling() | ||
|
||
Return the number of pending cancellation requests to this Task, i.e., | ||
the number of calls to :meth:`cancel` less the number of | ||
:meth:`uncancel` calls. | ||
|
||
Note that if this number is greater than zero but the Task is | ||
still executing, :meth:`cancelled` will still return ``False``. | ||
This is because this number can be lowered by calling :meth:`uncancel`, | ||
which can lead to the task not being cancelled after all if the | ||
cancellation requests go down to zero. | ||
Comment on lines
+1233
to
+1237
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems to imply that the effect of See also the comment added to |
||
|
||
This method is used by asyncio's internals and isn't expected to be | ||
used by end-user code. See :meth:`uncancel` for more details. | ||
|
||
.. versionadded:: 3.11 |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -243,8 +243,8 @@ def cancelling(self): | |
def uncancel(self): | ||
"""Decrement the task's count of cancellation requests. | ||
|
||
This should be used by tasks that catch CancelledError | ||
and wish to continue indefinitely until they are cancelled again. | ||
This should be called by the party that called `cancel()` on the task | ||
beforehand. | ||
Comment on lines
-246
to
+247
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The previous docstring was invalid, we actively don't want for user code to call |
||
|
||
Returns the remaining number of cancellation requests. | ||
""" | ||
|
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -521,7 +521,7 @@ async def task(): | |||||
finally: | ||||||
loop.close() | ||||||
|
||||||
def test_uncancel(self): | ||||||
def test_uncancel_basic(self): | ||||||
loop = asyncio.new_event_loop() | ||||||
|
||||||
async def task(): | ||||||
|
@@ -534,17 +534,137 @@ async def task(): | |||||
try: | ||||||
t = self.new_task(loop, task()) | ||||||
loop.run_until_complete(asyncio.sleep(0.01)) | ||||||
self.assertTrue(t.cancel()) # Cancel first sleep | ||||||
|
||||||
# Cancel first sleep | ||||||
self.assertTrue(t.cancel()) | ||||||
self.assertIn(" cancelling ", repr(t)) | ||||||
self.assertEqual(t.cancelling(), 1) | ||||||
self.assertFalse(t.cancelled()) # Task is still not complete | ||||||
loop.run_until_complete(asyncio.sleep(0.01)) | ||||||
self.assertNotIn(" cancelling ", repr(t)) # after .uncancel() | ||||||
self.assertTrue(t.cancel()) # Cancel second sleep | ||||||
|
||||||
# after .uncancel() | ||||||
self.assertNotIn(" cancelling ", repr(t)) | ||||||
self.assertEqual(t.cancelling(), 0) | ||||||
self.assertFalse(t.cancelled()) # Task is still not complete | ||||||
|
||||||
# Cancel second sleep | ||||||
self.assertTrue(t.cancel()) | ||||||
self.assertEqual(t.cancelling(), 1) | ||||||
self.assertFalse(t.cancelled()) # Task is still not complete | ||||||
with self.assertRaises(asyncio.CancelledError): | ||||||
loop.run_until_complete(t) | ||||||
self.assertTrue(t.cancelled()) # Finally, task complete | ||||||
self.assertTrue(t.done()) | ||||||
|
||||||
# uncancel is no longer effective after the task is complete | ||||||
t.uncancel() | ||||||
self.assertTrue(t.cancelled()) | ||||||
self.assertTrue(t.done()) | ||||||
finally: | ||||||
loop.close() | ||||||
|
||||||
def test_uncancel_structured_blocks(self): | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd like you to look at this test and tell me if you think anything here (esp. the comments!) is not factual. |
||||||
# This test recreates the following high-level structure using uncancel():: | ||||||
# | ||||||
# async def make_request_with_timeout(): | ||||||
# try: | ||||||
# async with asyncio.timeout(1): | ||||||
# # Structured block affected by the timeout: | ||||||
# await make_request() | ||||||
# await make_another_request() | ||||||
# except TimeoutError: | ||||||
# pass # There was a timeout | ||||||
# # Outer code not affected by the timeout: | ||||||
# await unrelated_code() | ||||||
|
||||||
loop = asyncio.new_event_loop() | ||||||
|
||||||
async def make_request_with_timeout(*, sleep: float, timeout: float): | ||||||
task = asyncio.current_task() | ||||||
loop = task.get_loop() | ||||||
|
||||||
timed_out = False | ||||||
structured_block_finished = False | ||||||
outer_code_reached = False | ||||||
|
||||||
def on_timeout(): | ||||||
nonlocal timed_out | ||||||
timed_out = True | ||||||
task.cancel() | ||||||
|
||||||
timeout_handle = loop.call_later(timeout, on_timeout) | ||||||
try: | ||||||
try: | ||||||
# Structured block affected by the timeout | ||||||
await asyncio.sleep(sleep) | ||||||
structured_block_finished = True | ||||||
finally: | ||||||
timeout_handle.cancel() | ||||||
if ( | ||||||
timed_out | ||||||
and task.uncancel() == 0 | ||||||
and sys.exc_info()[0] is asyncio.CancelledError | ||||||
): | ||||||
# Note the five rules that are needed here to satisfy proper | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Canceling the stimulus (timeouts), or guarding against cancelling after calling uncancel() (TaskGroup and Runner) I think is a sixth rule
Suggested change
|
||||||
# uncancellation: | ||||||
# | ||||||
# 1. handle uncancellation in a `finally:` block to allow for | ||||||
# plain returns; | ||||||
# 2. our `timed_out` flag is set, meaning that it was our event | ||||||
# that triggered the need to uncancel the task, regardless of | ||||||
# what exception is raised; | ||||||
# 3. we can call `uncancel()` because *we* called `cancel()` | ||||||
# before; | ||||||
# 4. we call `uncancel()` but we only continue converting the | ||||||
# CancelledError to TimeoutError if `uncancel()` caused the | ||||||
# cancellation request count go down to 0. We need to look | ||||||
# at the counter vs having a simple boolean flag because our | ||||||
# code might have been nested (think multiple timeouts). See | ||||||
# commit 7fce1063b6e5a366f8504e039a8ccdd6944625cd for | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Note that commit 7d611b4 changes the behavior again. |
||||||
# details. | ||||||
# 5. we only convert CancelledError to TimeoutError; for other | ||||||
# exceptions raised due to the cancellation (like | ||||||
# a ConnectionLostError from a database client), simply | ||||||
# propagate them. | ||||||
# | ||||||
# Those checks need to take place in this exact order to make | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Those checks" == (2), (4), (5), right? Because (1) and (3) don't describe "checks". Other than that nit, these comments seem correct, and the code looks so too (but I'm not taking money :-). (Another nit: the huge comment interrupts the logic. Maybe move it to the top of the test function?) |
||||||
# sure the `cancelling()` counter always stays in sync. | ||||||
# | ||||||
# Additionally, the original stimulus to `cancel()` the task | ||||||
# needs to be unscheduled to avoid re-cancelling the task later. | ||||||
# Here we do it by cancelling `timeout_handle` in the `finally:` | ||||||
# block. | ||||||
raise TimeoutError | ||||||
except TimeoutError: | ||||||
self.assertTrue(timed_out) | ||||||
|
||||||
# Outer code not affected by the timeout: | ||||||
outer_code_reached = True | ||||||
await asyncio.sleep(0) | ||||||
ambv marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
return timed_out, structured_block_finished, outer_code_reached | ||||||
|
||||||
# Test which timed out. | ||||||
t1 = self.new_task(loop, make_request_with_timeout(sleep=10.0, timeout=0.1)) | ||||||
timed_out, structured_block_finished, outer_code_reached = ( | ||||||
loop.run_until_complete(t1) | ||||||
) | ||||||
self.assertTrue(timed_out) | ||||||
self.assertFalse(structured_block_finished) # it was cancelled | ||||||
self.assertTrue(outer_code_reached) # task got uncancelled after leaving | ||||||
# the structured block and continued until | ||||||
# completion | ||||||
self.assertEqual(t1.cancelling(), 0) # no pending cancellation of the outer task | ||||||
|
||||||
# Test which did not time out. | ||||||
t2 = self.new_task(loop, make_request_with_timeout(sleep=0, timeout=10.0)) | ||||||
timed_out, structured_block_finished, outer_code_reached = ( | ||||||
loop.run_until_complete(t2) | ||||||
) | ||||||
self.assertFalse(timed_out) | ||||||
self.assertTrue(structured_block_finished) | ||||||
self.assertTrue(outer_code_reached) | ||||||
self.assertEqual(t2.cancelling(), 0) | ||||||
|
||||||
def test_cancel(self): | ||||||
|
||||||
def gen(): | ||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This part is unchanged, only moved down. IMO cancellation isn't as important as getting other things out of a task. Plus this move allows us to keep cancel-specific methods next to each other,
uncancel
in particular being pretty low-level.