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

Skip to content

Conversation

@achimnol
Copy link
Contributor

@achimnol achimnol commented May 27, 2023

This is yet another approach to embrace long-lived use cases of TaskGroup, as an alternative to #101648.

It adds a new lower-level task lifecycle management primitive, TaskScope. It provides a base scope for cancellation and tracking of child tasks, and can be extended to add specific semantics like TaskGroup which cancels all children altogether upon any first seen unhandled exception. aiotools demonstrates other new coroutine aggregation interfaces like race(), as_completed_safe(), and gather_safe() based on TaskScope. (Note: the current aiotools.Supervisor is same to TaskScope(delegate_errors=None), so TaskScope is a generalization of Supervisor.)

The implementation combines the 1st and the 2nd ideas as discussed about expressing how to handle the exceptions: delegate_errors.

  • TaskScope(): Calls loop.call_exception_handler() upon unhandled exceptions
  • TaskScope(delegate_errors=None): Silently ignores unhandled exceptions
    • Discouraged for end-users but useful for library writers (e.g., TaskGroup)
  • TaskScope(delegate_errors=func): Calls func using the same context argument like loop.call_exception_handler()

TaskGroup is rewritten by subclassing TaskScope, and now its code highlights the specific semantics of the TaskGroup API. The rewritten TaskGroup still passes all existing test cases.

To prevent memory leaks in long-lived use cases, TaskScope just keeps a self._has_errors boolean flag only while TaskGroup keeps the full list self._errors.

class TaskGroup(taskscopes.TaskScope):
    def __init__(self):
        super().__init__(delegate_errors=None)
        self._errors = []

    def __repr__(self): ...

    def create_task(self, coro, *, name=None, context=None):
        """Create a new task in this group and return it.

        Similar to `asyncio.create_task`.
        """
        task = super().create_task(coro, name=name, context=context)
        if not task.done():
            task.add_done_callback(self._handle_completion_as_group)
        return task

    async def __aexit__(self, et, exc, tb):
        await super().__aexit__(et, exc, tb)

        if et is not None and et is not exceptions.CancelledError:
            self._errors.append(exc)

        if self._errors:
            # Exceptions are heavy objects that can have object
            # cycles (bad for GC); let's not keep a reference to
            # a bunch of them.
            try:
                me = BaseExceptionGroup('unhandled errors in a TaskGroup', self._errors)
                raise me from None
            finally:
                self._errors = None

    def _handle_completion_as_group(self, task):
        if task.cancelled():
            return
        if (exc := task.exception()) is not None:
            self._errors.append(exc)
            if not self._aborting and not self._parent_cancel_requested:
                self._abort()
                self._parent_cancel_requested = True
                self._parent_task.cancel()

The rewritten TaskGroup still passes all existing test cases.
achimnol added 2 commits May 27, 2023 10:22
- Also fixes `__all__` import of the taskgroups and taskscope modules
- asyncio's module names are plural.
@achimnol
Copy link
Contributor Author

I'm work in progress to revamp the test cases copied from #101648.

@achimnol
Copy link
Contributor Author

Under experimentation in achimnol/aiotools#58.

@1st1
Copy link
Member

1st1 commented Sep 12, 2024

@achimnol, in general I like the solution. However, I'm wondering if instead of using inheritance we can somehow use composition for this.

@achimnol
Copy link
Contributor Author

achimnol commented Oct 4, 2025

I'm going to restart the experimentation including combination with cancel_and_wait() in #103486 during this Chuseok holiday. So far, TaskScope looks mingling well with cancel_and_wait().

@achimnol
Copy link
Contributor Author

achimnol commented Oct 12, 2025

I've done context-manager-style shielding via TaskScope(shield=True) and ShieldScope in achimnol/aiotools#93 which can be mixed together with asyncio.TaskGroup.

https://aiotools.readthedocs.io/en/latest/aiotools.taskscope.html#aiotools.taskscope.ShieldScope
@1st1 ShieldScope is a subclass to TaskScope(shield=True), and in the source code you could see how I decompose the stages within __aenter__() and __aexit__() to reuse most codes. I think TaskGroup based on TaskScope could use a similar approach, by decomposing the on_task_done() callback.

The aiotools implementation is mostly independent to asyncio internals, but it requires overriding asyncio.Task.{cancel,uncancel,cancelling}() methods to defer the cancellation requests until exiting the outmost shield scope to allow the code blocks (not only subtasks!) run to completion within the shield block.
(Example: https://github.com/achimnol/aiotools/blob/13bccfa6708a9b1bd1c5d7523914b36464d16090/tests/test_taskscope.py#L625)

I'm currently thinking about how this could be implemented into the stdlib's asyncio.Task and how I could add the shield option to asyncio.TaskGroup.

@achimnol
Copy link
Contributor Author

achimnol commented Oct 12, 2025

@1st1 @asvetlov @kumaraditya303 @gvanrossum I have several concerns before continuing the work in this PR:

  • Would it be acceptable to add parent-schild scope relationship tracking in the stdlib (in the perspective of overheads and performance)?
  • For anyio and other "deeply integrated" libraries, it would be nice to have some way to extend the asyncio.Task._run_once() and add user-defined properties to asyncio.Task to avoid having an external per-task data registry. (cc: @agronholm)
  • What would be a better API to change the exception collection behavior? I've updated TaskScope and TaskContext to use exception_handler which uses the same callback signature to loop.call_exception_handler(). Making it optional to switch between callback and ExceptionGroup would require a more fine-grained decomposition of TaskScope and TaskGroup internal logic.
  • When decomposing the existing methods, what would be the favored way to abstract them? e.g., just having split instance methods (naming rules?), decomposing it as mixin or other class-based patterns, etc.

@agronholm
Copy link
Contributor

Can we take a step back and go back to what this PR wants to achieve? A summary would be great for everyone seeing this for the first time.

@achimnol
Copy link
Contributor Author

achimnol commented Oct 14, 2025

@agronholm

image

(revised at 2025-10-15; before revision)

This is what I understand now.
Now I'm thinking about limiting cancellation via scopes only or making aiotools.TaskScope to reuse anyio.CancelScope.
The issue is that we need to make all codebase to use anyio.TaskGroup instead of asyncio.TaskGroup make everything consistent... 🤔

@achimnol
Copy link
Contributor Author

achimnol commented Oct 15, 2025

To split out cancellation scopes and task-managing scopes to make them reusable and composable, we need ability to customize asyncio.Task in many ways such as adding custom attributes (per-task state) and overriding cancellation methods. Both anyio and aiotools are suffering from inability of it and introducing "hacky" solutions in different ways.

Using a task factory could be one solution, but it has a crucial limitation: what if there are multiple libraries and user codes who want to install their own task factories?

Since asyncio is a stdlib, I'm afraid how far extent we could introduce some semantic (=breaking) changes to reconcile anyio and aiotools implementations.

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