Skip to content

Commit f00645d

Browse files
ambvgraingert
andauthored
gh-90908: Document asyncio.Task.cancelling() and asyncio.Task.uncancel() (#95253)
Co-authored-by: Thomas Grainger <[email protected]>
1 parent 273a819 commit f00645d

File tree

3 files changed

+254
-80
lines changed

3 files changed

+254
-80
lines changed

Doc/library/asyncio-task.rst

+128-74
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,13 @@ perform clean-up logic. In case :exc:`asyncio.CancelledError`
294294
is explicitly caught, it should generally be propagated when
295295
clean-up is complete. Most code can safely ignore :exc:`asyncio.CancelledError`.
296296

297-
Important asyncio components, like :class:`asyncio.TaskGroup` and the
298-
:func:`asyncio.timeout` context manager, are implemented using cancellation
299-
internally and might misbehave if a coroutine swallows
300-
:exc:`asyncio.CancelledError`.
297+
The asyncio components that enable structured concurrency, like
298+
:class:`asyncio.TaskGroup` and :func:`asyncio.timeout`,
299+
are implemented using cancellation internally and might misbehave if
300+
a coroutine swallows :exc:`asyncio.CancelledError`. Similarly, user code
301+
should not call :meth:`uncancel <asyncio.Task.uncancel>`.
301302

303+
.. _taskgroups:
302304

303305
Task Groups
304306
===========
@@ -1003,76 +1005,6 @@ Task Object
10031005
Deprecation warning is emitted if *loop* is not specified
10041006
and there is no running event loop.
10051007

1006-
.. method:: cancel(msg=None)
1007-
1008-
Request the Task to be cancelled.
1009-
1010-
This arranges for a :exc:`CancelledError` exception to be thrown
1011-
into the wrapped coroutine on the next cycle of the event loop.
1012-
1013-
The coroutine then has a chance to clean up or even deny the
1014-
request by suppressing the exception with a :keyword:`try` ...
1015-
... ``except CancelledError`` ... :keyword:`finally` block.
1016-
Therefore, unlike :meth:`Future.cancel`, :meth:`Task.cancel` does
1017-
not guarantee that the Task will be cancelled, although
1018-
suppressing cancellation completely is not common and is actively
1019-
discouraged.
1020-
1021-
.. versionchanged:: 3.9
1022-
Added the *msg* parameter.
1023-
1024-
.. deprecated-removed:: 3.11 3.14
1025-
*msg* parameter is ambiguous when multiple :meth:`cancel`
1026-
are called with different cancellation messages.
1027-
The argument will be removed.
1028-
1029-
.. _asyncio_example_task_cancel:
1030-
1031-
The following example illustrates how coroutines can intercept
1032-
the cancellation request::
1033-
1034-
async def cancel_me():
1035-
print('cancel_me(): before sleep')
1036-
1037-
try:
1038-
# Wait for 1 hour
1039-
await asyncio.sleep(3600)
1040-
except asyncio.CancelledError:
1041-
print('cancel_me(): cancel sleep')
1042-
raise
1043-
finally:
1044-
print('cancel_me(): after sleep')
1045-
1046-
async def main():
1047-
# Create a "cancel_me" Task
1048-
task = asyncio.create_task(cancel_me())
1049-
1050-
# Wait for 1 second
1051-
await asyncio.sleep(1)
1052-
1053-
task.cancel()
1054-
try:
1055-
await task
1056-
except asyncio.CancelledError:
1057-
print("main(): cancel_me is cancelled now")
1058-
1059-
asyncio.run(main())
1060-
1061-
# Expected output:
1062-
#
1063-
# cancel_me(): before sleep
1064-
# cancel_me(): cancel sleep
1065-
# cancel_me(): after sleep
1066-
# main(): cancel_me is cancelled now
1067-
1068-
.. method:: cancelled()
1069-
1070-
Return ``True`` if the Task is *cancelled*.
1071-
1072-
The Task is *cancelled* when the cancellation was requested with
1073-
:meth:`cancel` and the wrapped coroutine propagated the
1074-
:exc:`CancelledError` exception thrown into it.
1075-
10761008
.. method:: done()
10771009

10781010
Return ``True`` if the Task is *done*.
@@ -1186,3 +1118,125 @@ Task Object
11861118
in the :func:`repr` output of a task object.
11871119

11881120
.. versionadded:: 3.8
1121+
1122+
.. method:: cancel(msg=None)
1123+
1124+
Request the Task to be cancelled.
1125+
1126+
This arranges for a :exc:`CancelledError` exception to be thrown
1127+
into the wrapped coroutine on the next cycle of the event loop.
1128+
1129+
The coroutine then has a chance to clean up or even deny the
1130+
request by suppressing the exception with a :keyword:`try` ...
1131+
... ``except CancelledError`` ... :keyword:`finally` block.
1132+
Therefore, unlike :meth:`Future.cancel`, :meth:`Task.cancel` does
1133+
not guarantee that the Task will be cancelled, although
1134+
suppressing cancellation completely is not common and is actively
1135+
discouraged.
1136+
1137+
.. versionchanged:: 3.9
1138+
Added the *msg* parameter.
1139+
1140+
.. deprecated-removed:: 3.11 3.14
1141+
*msg* parameter is ambiguous when multiple :meth:`cancel`
1142+
are called with different cancellation messages.
1143+
The argument will be removed.
1144+
1145+
.. _asyncio_example_task_cancel:
1146+
1147+
The following example illustrates how coroutines can intercept
1148+
the cancellation request::
1149+
1150+
async def cancel_me():
1151+
print('cancel_me(): before sleep')
1152+
1153+
try:
1154+
# Wait for 1 hour
1155+
await asyncio.sleep(3600)
1156+
except asyncio.CancelledError:
1157+
print('cancel_me(): cancel sleep')
1158+
raise
1159+
finally:
1160+
print('cancel_me(): after sleep')
1161+
1162+
async def main():
1163+
# Create a "cancel_me" Task
1164+
task = asyncio.create_task(cancel_me())
1165+
1166+
# Wait for 1 second
1167+
await asyncio.sleep(1)
1168+
1169+
task.cancel()
1170+
try:
1171+
await task
1172+
except asyncio.CancelledError:
1173+
print("main(): cancel_me is cancelled now")
1174+
1175+
asyncio.run(main())
1176+
1177+
# Expected output:
1178+
#
1179+
# cancel_me(): before sleep
1180+
# cancel_me(): cancel sleep
1181+
# cancel_me(): after sleep
1182+
# main(): cancel_me is cancelled now
1183+
1184+
.. method:: cancelled()
1185+
1186+
Return ``True`` if the Task is *cancelled*.
1187+
1188+
The Task is *cancelled* when the cancellation was requested with
1189+
:meth:`cancel` and the wrapped coroutine propagated the
1190+
:exc:`CancelledError` exception thrown into it.
1191+
1192+
.. method:: uncancel()
1193+
1194+
Decrement the count of cancellation requests to this Task.
1195+
1196+
Returns the remaining number of cancellation requests.
1197+
1198+
Note that once execution of a cancelled task completed, further
1199+
calls to :meth:`uncancel` are ineffective.
1200+
1201+
.. versionadded:: 3.11
1202+
1203+
This method is used by asyncio's internals and isn't expected to be
1204+
used by end-user code. In particular, if a Task gets successfully
1205+
uncancelled, this allows for elements of structured concurrency like
1206+
:ref:`taskgroups` and :func:`asyncio.timeout` to continue running,
1207+
isolating cancellation to the respective structured block.
1208+
For example::
1209+
1210+
async def make_request_with_timeout():
1211+
try:
1212+
async with asyncio.timeout(1):
1213+
# Structured block affected by the timeout:
1214+
await make_request()
1215+
await make_another_request()
1216+
except TimeoutError:
1217+
log("There was a timeout")
1218+
# Outer code not affected by the timeout:
1219+
await unrelated_code()
1220+
1221+
While the block with ``make_request()`` and ``make_another_request()``
1222+
might get cancelled due to the timeout, ``unrelated_code()`` should
1223+
continue running even in case of the timeout. This is implemented
1224+
with :meth:`uncancel`. :class:`TaskGroup` context managers use
1225+
:func:`uncancel` in a similar fashion.
1226+
1227+
.. method:: cancelling()
1228+
1229+
Return the number of pending cancellation requests to this Task, i.e.,
1230+
the number of calls to :meth:`cancel` less the number of
1231+
:meth:`uncancel` calls.
1232+
1233+
Note that if this number is greater than zero but the Task is
1234+
still executing, :meth:`cancelled` will still return ``False``.
1235+
This is because this number can be lowered by calling :meth:`uncancel`,
1236+
which can lead to the task not being cancelled after all if the
1237+
cancellation requests go down to zero.
1238+
1239+
This method is used by asyncio's internals and isn't expected to be
1240+
used by end-user code. See :meth:`uncancel` for more details.
1241+
1242+
.. versionadded:: 3.11

Lib/asyncio/tasks.py

+2-2
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,8 @@ def cancelling(self):
243243
def uncancel(self):
244244
"""Decrement the task's count of cancellation requests.
245245
246-
This should be used by tasks that catch CancelledError
247-
and wish to continue indefinitely until they are cancelled again.
246+
This should be called by the party that called `cancel()` on the task
247+
beforehand.
248248
249249
Returns the remaining number of cancellation requests.
250250
"""

Lib/test/test_asyncio/test_tasks.py

+124-4
Original file line numberDiff line numberDiff line change
@@ -521,7 +521,7 @@ async def task():
521521
finally:
522522
loop.close()
523523

524-
def test_uncancel(self):
524+
def test_uncancel_basic(self):
525525
loop = asyncio.new_event_loop()
526526

527527
async def task():
@@ -534,17 +534,137 @@ async def task():
534534
try:
535535
t = self.new_task(loop, task())
536536
loop.run_until_complete(asyncio.sleep(0.01))
537-
self.assertTrue(t.cancel()) # Cancel first sleep
537+
538+
# Cancel first sleep
539+
self.assertTrue(t.cancel())
538540
self.assertIn(" cancelling ", repr(t))
541+
self.assertEqual(t.cancelling(), 1)
542+
self.assertFalse(t.cancelled()) # Task is still not complete
539543
loop.run_until_complete(asyncio.sleep(0.01))
540-
self.assertNotIn(" cancelling ", repr(t)) # after .uncancel()
541-
self.assertTrue(t.cancel()) # Cancel second sleep
542544

545+
# after .uncancel()
546+
self.assertNotIn(" cancelling ", repr(t))
547+
self.assertEqual(t.cancelling(), 0)
548+
self.assertFalse(t.cancelled()) # Task is still not complete
549+
550+
# Cancel second sleep
551+
self.assertTrue(t.cancel())
552+
self.assertEqual(t.cancelling(), 1)
553+
self.assertFalse(t.cancelled()) # Task is still not complete
543554
with self.assertRaises(asyncio.CancelledError):
544555
loop.run_until_complete(t)
556+
self.assertTrue(t.cancelled()) # Finally, task complete
557+
self.assertTrue(t.done())
558+
559+
# uncancel is no longer effective after the task is complete
560+
t.uncancel()
561+
self.assertTrue(t.cancelled())
562+
self.assertTrue(t.done())
545563
finally:
546564
loop.close()
547565

566+
def test_uncancel_structured_blocks(self):
567+
# This test recreates the following high-level structure using uncancel()::
568+
#
569+
# async def make_request_with_timeout():
570+
# try:
571+
# async with asyncio.timeout(1):
572+
# # Structured block affected by the timeout:
573+
# await make_request()
574+
# await make_another_request()
575+
# except TimeoutError:
576+
# pass # There was a timeout
577+
# # Outer code not affected by the timeout:
578+
# await unrelated_code()
579+
580+
loop = asyncio.new_event_loop()
581+
582+
async def make_request_with_timeout(*, sleep: float, timeout: float):
583+
task = asyncio.current_task()
584+
loop = task.get_loop()
585+
586+
timed_out = False
587+
structured_block_finished = False
588+
outer_code_reached = False
589+
590+
def on_timeout():
591+
nonlocal timed_out
592+
timed_out = True
593+
task.cancel()
594+
595+
timeout_handle = loop.call_later(timeout, on_timeout)
596+
try:
597+
try:
598+
# Structured block affected by the timeout
599+
await asyncio.sleep(sleep)
600+
structured_block_finished = True
601+
finally:
602+
timeout_handle.cancel()
603+
if (
604+
timed_out
605+
and task.uncancel() == 0
606+
and sys.exc_info()[0] is asyncio.CancelledError
607+
):
608+
# Note the five rules that are needed here to satisfy proper
609+
# uncancellation:
610+
#
611+
# 1. handle uncancellation in a `finally:` block to allow for
612+
# plain returns;
613+
# 2. our `timed_out` flag is set, meaning that it was our event
614+
# that triggered the need to uncancel the task, regardless of
615+
# what exception is raised;
616+
# 3. we can call `uncancel()` because *we* called `cancel()`
617+
# before;
618+
# 4. we call `uncancel()` but we only continue converting the
619+
# CancelledError to TimeoutError if `uncancel()` caused the
620+
# cancellation request count go down to 0. We need to look
621+
# at the counter vs having a simple boolean flag because our
622+
# code might have been nested (think multiple timeouts). See
623+
# commit 7fce1063b6e5a366f8504e039a8ccdd6944625cd for
624+
# details.
625+
# 5. we only convert CancelledError to TimeoutError; for other
626+
# exceptions raised due to the cancellation (like
627+
# a ConnectionLostError from a database client), simply
628+
# propagate them.
629+
#
630+
# Those checks need to take place in this exact order to make
631+
# sure the `cancelling()` counter always stays in sync.
632+
#
633+
# Additionally, the original stimulus to `cancel()` the task
634+
# needs to be unscheduled to avoid re-cancelling the task later.
635+
# Here we do it by cancelling `timeout_handle` in the `finally:`
636+
# block.
637+
raise TimeoutError
638+
except TimeoutError:
639+
self.assertTrue(timed_out)
640+
641+
# Outer code not affected by the timeout:
642+
outer_code_reached = True
643+
await asyncio.sleep(0)
644+
return timed_out, structured_block_finished, outer_code_reached
645+
646+
# Test which timed out.
647+
t1 = self.new_task(loop, make_request_with_timeout(sleep=10.0, timeout=0.1))
648+
timed_out, structured_block_finished, outer_code_reached = (
649+
loop.run_until_complete(t1)
650+
)
651+
self.assertTrue(timed_out)
652+
self.assertFalse(structured_block_finished) # it was cancelled
653+
self.assertTrue(outer_code_reached) # task got uncancelled after leaving
654+
# the structured block and continued until
655+
# completion
656+
self.assertEqual(t1.cancelling(), 0) # no pending cancellation of the outer task
657+
658+
# Test which did not time out.
659+
t2 = self.new_task(loop, make_request_with_timeout(sleep=0, timeout=10.0))
660+
timed_out, structured_block_finished, outer_code_reached = (
661+
loop.run_until_complete(t2)
662+
)
663+
self.assertFalse(timed_out)
664+
self.assertTrue(structured_block_finished)
665+
self.assertTrue(outer_code_reached)
666+
self.assertEqual(t2.cancelling(), 0)
667+
548668
def test_cancel(self):
549669

550670
def gen():

0 commit comments

Comments
 (0)