Skip to content
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

gh-90908: Document asyncio.Task.cancelling() and asyncio.Task.uncancel() #95253

Merged
merged 12 commits into from
Oct 1, 2022
Merged
231 changes: 157 additions & 74 deletions Doc/library/asyncio-task.rst
Original file line number Diff line number Diff line change
Expand Up @@ -294,11 +294,14 @@ 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`.
asyncio components that enable structured concurrency, 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`. In particular,
they might :func:`uncancel <asyncio.Task.uncancel>` a task to properly
isolate cancelling only a given structured block within the task's body.

.. _taskgroups:

Task Groups
===========
Expand Down Expand Up @@ -994,76 +997,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*.
Expand Down Expand Up @@ -1177,3 +1110,153 @@ 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.

Comment on lines +1122 to +1191
Copy link
Contributor Author

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.

.. method:: cancelling()

Return the number of cancellation requests to this Task, i.e.,
the number of calls to :meth:`cancel`.

Note that if this number is greater than zero but the Task is
still executing, :meth:`cancelled` will still return ``False``.
It's 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.

.. versionadded:: 3.11

.. 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
uncancelled, this allows for elements of structured concurrency like
:ref:`taskgroups` or 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 can be
implemented with :meth:`uncancel` as follows::
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure if we need to give an implementation of a structured concurrency primitive as an example in the docs, given that we don't expect (or want!) people to do this. I also don't have time to review the example carefully enough to trust it doesn't have bugs that would be replicated if people copy this example.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I moved the example to tests but kept it there as reference docs for our own purposes.


async def make_request_with_timeout():
task = asyncio.current_task()
loop = task.get_loop()
i_called_cancel = False

def on_timeout():
nonlocal i_called_cancel
i_called_cancel = True
task.cancel()

timeout_handle = loop.call_later(1, on_timeout)
try:
try:
# Structured block affected by the timeout
await make_request()
await make_another_request()
finally:
timeout_handle.cancel()
if (
i_called_cancel
and task.uncancel() == 0
and sys.exc_info()[0] is asyncio.CancelledError
):
raise TimeoutError
except TimeoutError:
log("There was a timeout")

# Outer code not affected by the timeout:
await unrelated_code()

:class:`TaskGroup` context managers use :func:`uncancel` in
a similar fashion.
24 changes: 21 additions & 3 deletions Lib/test/test_asyncio/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,14 +534,32 @@ 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()

Expand Down