Skip to content

Commit 95559d2

Browse files
belm0sobolevnasvetlovgvanrossum
authored
gh-108951: add TaskGroup.cancel() (#127214)
Fixes #108951 Co-authored-by: sobolevn <mail@sobolevn.me> Co-authored-by: Andrew Svetlov <andrew.svetlov@gmail.com> Co-authored-by: Guido van Rossum <guido@python.org>
1 parent 665b7df commit 95559d2

5 files changed

Lines changed: 191 additions & 48 deletions

File tree

Doc/library/asyncio-task.rst

Lines changed: 30 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,34 @@ and reliable way to wait for all tasks in the group to finish.
355355

356356
Passes on all *kwargs* to :meth:`loop.create_task`
357357

358+
.. method:: cancel()
359+
360+
Cancel the task group. This is a non-exceptional, early exit of the
361+
task group's lifetime -- useful once the group's goal has been met or
362+
its services no longer needed.
363+
364+
:meth:`~asyncio.Task.cancel` will be called on any tasks in the group that
365+
aren't yet done, as well as the parent (body) of the group. The task group
366+
context manager will exit *without* :exc:`asyncio.CancelledError` being raised.
367+
368+
If :meth:`cancel` is called before entering the task group, the group will be
369+
cancelled upon entry. This is useful for patterns where one piece of
370+
code passes an unused :class:`asyncio.TaskGroup` instance to another in order to have
371+
the ability to cancel anything run within the group.
372+
373+
:meth:`cancel` is idempotent and may be called after the task group has
374+
already exited.
375+
376+
Some ways to use :meth:`cancel`:
377+
378+
* call it from the task group body based on some condition or event
379+
* pass the task group instance to child tasks via :meth:`create_task`, allowing a child
380+
task to conditionally cancel the entire entire group
381+
* pass the task group instance or bound :meth:`cancel` method to some other task *before*
382+
opening the task group, allowing remote cancellation
383+
384+
.. versionadded:: next
385+
358386
Example::
359387

360388
async def main():
@@ -366,7 +394,8 @@ Example::
366394
The ``async with`` statement will wait for all tasks in the group to finish.
367395
While waiting, new tasks may still be added to the group
368396
(for example, by passing ``tg`` into one of the coroutines
369-
and calling ``tg.create_task()`` in that coroutine).
397+
and calling ``tg.create_task()`` in that coroutine). There is also opportunity
398+
to short-circuit the entire task group with ``tg.cancel()``, based on some condition.
370399
Once the last task has finished and the ``async with`` block is exited,
371400
no new tasks may be added to the group.
372401

@@ -427,53 +456,6 @@ reported by :meth:`asyncio.Task.cancelling`.
427456
Improved handling of simultaneous internal and external cancellations
428457
and correct preservation of cancellation counts.
429458

430-
Terminating a task group
431-
------------------------
432-
433-
While terminating a task group is not natively supported by the standard
434-
library, termination can be achieved by adding an exception-raising task
435-
to the task group and ignoring the raised exception:
436-
437-
.. code-block:: python
438-
439-
import asyncio
440-
from asyncio import TaskGroup
441-
442-
class TerminateTaskGroup(Exception):
443-
"""Exception raised to terminate a task group."""
444-
445-
async def force_terminate_task_group():
446-
"""Used to force termination of a task group."""
447-
raise TerminateTaskGroup()
448-
449-
async def job(task_id, sleep_time):
450-
print(f'Task {task_id}: start')
451-
await asyncio.sleep(sleep_time)
452-
print(f'Task {task_id}: done')
453-
454-
async def main():
455-
try:
456-
async with TaskGroup() as group:
457-
# spawn some tasks
458-
group.create_task(job(1, 0.5))
459-
group.create_task(job(2, 1.5))
460-
# sleep for 1 second
461-
await asyncio.sleep(1)
462-
# add an exception-raising task to force the group to terminate
463-
group.create_task(force_terminate_task_group())
464-
except* TerminateTaskGroup:
465-
pass
466-
467-
asyncio.run(main())
468-
469-
Expected output:
470-
471-
.. code-block:: text
472-
473-
Task 1: start
474-
Task 2: start
475-
Task 1: done
476-
477459
Sleeping
478460
========
479461

Doc/tools/removed-ids.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,5 @@
33
# Remove from here in 3.16
44
c-api/allocation.html: deprecated-aliases
55
c-api/file.html: deprecated-api
6+
7+
library/asyncio-task.html: terminating-a-task-group

Lib/asyncio/taskgroups.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ def __init__(self):
3737
self._errors = []
3838
self._base_error = None
3939
self._on_completed_fut = None
40+
self._cancel_on_enter = False
4041

4142
def __repr__(self):
4243
info = ['']
@@ -63,6 +64,8 @@ async def __aenter__(self):
6364
raise RuntimeError(
6465
f'TaskGroup {self!r} cannot determine the parent task')
6566
self._entered = True
67+
if self._cancel_on_enter:
68+
self.cancel()
6669

6770
return self
6871

@@ -178,6 +181,9 @@ async def _aexit(self, et, exc):
178181
finally:
179182
exc = None
180183

184+
# Suppress any remaining exception (exceptions deserving to be raised
185+
# were raised above).
186+
return True
181187

182188
def create_task(self, coro, **kwargs):
183189
"""Create a new task in this group and return it.
@@ -278,3 +284,30 @@ def _on_task_done(self, task):
278284
self._abort()
279285
self._parent_cancel_requested = True
280286
self._parent_task.cancel()
287+
288+
def cancel(self):
289+
"""Cancel the task group
290+
291+
`cancel()` will be called on any tasks in the group that aren't yet
292+
done, as well as the parent (body) of the group. This will cause the
293+
task group context manager to exit *without* `asyncio.CancelledError`
294+
being raised.
295+
296+
If `cancel()` is called before entering the task group, the group will be
297+
cancelled upon entry. This is useful for patterns where one piece of
298+
code passes an unused TaskGroup instance to another in order to have
299+
the ability to cancel anything run within the group.
300+
301+
`cancel()` is idempotent and may be called after the task group has
302+
already exited.
303+
"""
304+
if not self._entered:
305+
self._cancel_on_enter = True
306+
return
307+
if self._exiting and not self._tasks:
308+
return
309+
if not self._aborting:
310+
self._abort()
311+
if self._parent_task and not self._parent_cancel_requested:
312+
self._parent_cancel_requested = True
313+
self._parent_task.cancel()

Lib/test/test_asyncio/test_taskgroups.py

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,6 +1102,131 @@ async def throw_error():
11021102
# cancellation happens here and error is more understandable
11031103
await asyncio.sleep(0)
11041104

1105+
async def test_taskgroup_cancel_children(self):
1106+
# (asserting that TimeoutError is not raised)
1107+
async with asyncio.timeout(1):
1108+
async with asyncio.TaskGroup() as tg:
1109+
tg.create_task(asyncio.sleep(10))
1110+
tg.create_task(asyncio.sleep(10))
1111+
await asyncio.sleep(0)
1112+
tg.cancel()
1113+
1114+
async def test_taskgroup_cancel_body(self):
1115+
count = 0
1116+
async with asyncio.TaskGroup() as tg:
1117+
tg.cancel()
1118+
count += 1
1119+
await asyncio.sleep(0)
1120+
count += 1
1121+
self.assertEqual(count, 1)
1122+
1123+
async def test_taskgroup_cancel_idempotent(self):
1124+
count = 0
1125+
async with asyncio.TaskGroup() as tg:
1126+
tg.cancel()
1127+
tg.cancel()
1128+
count += 1
1129+
await asyncio.sleep(0)
1130+
count += 1
1131+
self.assertEqual(count, 1)
1132+
1133+
async def test_taskgroup_cancel_after_exit(self):
1134+
async with asyncio.TaskGroup() as tg:
1135+
await asyncio.sleep(0)
1136+
# (asserting that exception is not raised)
1137+
tg.cancel()
1138+
1139+
async def test_taskgroup_cancel_before_enter(self):
1140+
tg = asyncio.TaskGroup()
1141+
tg.cancel()
1142+
count = 0
1143+
async with tg:
1144+
count += 1
1145+
await asyncio.sleep(0)
1146+
count += 1
1147+
self.assertEqual(count, 1)
1148+
1149+
async def test_taskgroup_cancel_before_create_task(self):
1150+
async with asyncio.TaskGroup() as tg:
1151+
tg.cancel()
1152+
# TODO: This behavior is not ideal. We'd rather have no exception
1153+
# raised, and the child task run until the first await.
1154+
with self.assertRaises(RuntimeError):
1155+
tg.create_task(asyncio.sleep(1))
1156+
1157+
async def test_taskgroup_cancel_before_exception(self):
1158+
async def raise_exc(parent_tg: asyncio.TaskGroup):
1159+
parent_tg.cancel()
1160+
raise RuntimeError
1161+
1162+
with self.assertRaises(ExceptionGroup):
1163+
async with asyncio.TaskGroup() as tg:
1164+
tg.create_task(raise_exc(tg))
1165+
await asyncio.sleep(1)
1166+
1167+
async def test_taskgroup_cancel_after_exception(self):
1168+
async def raise_exc(parent_tg: asyncio.TaskGroup):
1169+
try:
1170+
raise RuntimeError
1171+
finally:
1172+
parent_tg.cancel()
1173+
1174+
with self.assertRaises(ExceptionGroup):
1175+
async with asyncio.TaskGroup() as tg:
1176+
tg.create_task(raise_exc(tg))
1177+
await asyncio.sleep(1)
1178+
1179+
async def test_taskgroup_body_cancel_before_exception(self):
1180+
with self.assertRaises(ExceptionGroup):
1181+
async with asyncio.TaskGroup() as tg:
1182+
tg.cancel()
1183+
raise RuntimeError
1184+
1185+
async def test_taskgroup_body_cancel_after_exception(self):
1186+
with self.assertRaises(ExceptionGroup):
1187+
async with asyncio.TaskGroup() as tg:
1188+
try:
1189+
raise RuntimeError
1190+
finally:
1191+
tg.cancel()
1192+
1193+
async def test_taskgroup_cancel_one_winner(self):
1194+
async def race(*fns):
1195+
outcome = None
1196+
async def run(fn):
1197+
nonlocal outcome
1198+
outcome = await fn()
1199+
tg.cancel()
1200+
1201+
async with asyncio.TaskGroup() as tg:
1202+
for fn in fns:
1203+
tg.create_task(run(fn))
1204+
return outcome
1205+
1206+
event = asyncio.Event()
1207+
record = []
1208+
async def fn_1():
1209+
record.append("1 started")
1210+
await event.wait()
1211+
record.append("1 finished")
1212+
return 1
1213+
1214+
async def fn_2():
1215+
record.append("2 started")
1216+
await event.wait()
1217+
record.append("2 finished")
1218+
return 2
1219+
1220+
async def fn_3():
1221+
record.append("3 started")
1222+
event.set()
1223+
await asyncio.sleep(10)
1224+
record.append("3 finished")
1225+
return 3
1226+
1227+
self.assertEqual(await race(fn_1, fn_2, fn_3), 1)
1228+
self.assertListEqual(record, ["1 started", "2 started", "3 started", "1 finished"])
1229+
11051230

11061231
class TestTaskGroup(BaseTestTaskGroup, unittest.IsolatedAsyncioTestCase):
11071232
loop_factory = asyncio.EventLoop
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add :meth:`~asyncio.TaskGroup.cancel` which cancels unfinished tasks and exits the group without error.

0 commit comments

Comments
 (0)