As a part of PyCon US 2024 sprint, I’d like to make a discussion thread for task scope design improvements as I’ve been suggesting since last year.
Reference
- Revisiting PersistentTaskGroup with Kotlin's SupervisorScope
- Suggestion of a non-cancelling “persistent” task group variant (like SupervisorScope)
- Add `task_factory` to asyncio.start_server and friends
- Question about setting a specific task factory for all tasks spawned inside a task group
Last year, although @guido suggested me to write and submit a PR to extend the existing TaskGroup API, I could not finalize my own thoughts about the design of the exception handling interface. To continue the discussion and catch up the new things in Python 3.13, I’d like to open a new thread.
There are already people who understand and agree with the motivation of this issue, but I’m re-phrasing it for others in the community. It would be ideal to implement all of the suggested ideas and request for reviews by myself, but unfortunately it would take a long time for me as I cannot dedicate enough time for in-depth programming in recent years (I’m running a company as CTO). I’d like to strongly encourage other people to participate in the development and discussion.
My experimentations in 2023
aiotools.TaskScope
andaiotools.TaskContext
- refactor: Introduce TaskScope and TaskContext by achimnol · Pull Request #58 · achimnol/aiotools · GitHub
- TaskContext is a simple weakset container of a set of tasks, which can be cancelled together explicitly. It is the base class of TaskScope and TaskGroup.
- TaskScope is a TaskGroup variant that keeps running even when individual subtasks are cancelled or fails.
aiotools.as_completed_safe()
- It is guarded by a task scope, and generates a series of results from tasks.
- Now Python 3.13 has a new, almost same, async-generator-based
asyncio.as_completed()
.
aiotools.race()
- A happy-eyeball primitive with two modes of exception handling, written using
as_completed_safe()
:- Store and defer errors; continue until we have the first successful result
- Stop and raise upon the first error
- A happy-eyeball primitive with two modes of exception handling, written using
The overall tone here is to improve support for long-running server applications.
asyncio.TaskGroup
does its job well on a short-lived group of “run-to-completion” tasks.
Abstracting server applications as a tree of nested task “scopes”
An example web server:
- The root task scope
- Appliaction “A” task scope serving
/myapp-a
- Handler task scope: API handler coroutines for
/myapp-a/*
- Timer task scope: periodically executed tasks (timers)
- Background task scope: long-running async operations
- Handler task scope: API handler coroutines for
- Application “B” task scope serving
/myapp-b
- Handler task scope: API handler coroutines for
/myapp-b/*
- Timer task scope: periodically executed tasks (timers)
- Background task scope: long-running async operations
- Handler task scope: API handler coroutines for
- …
- Appliaction “A” task scope serving
In this design, the lifecycle of a database connection pool may be bound to application task scopes or the root task scope. We could insert more task scope levels to make explicit ordering of shutdown.
When shutting down, we need to shutdown the task scopes from the innermost ones to the outermost ones in order. It would be nice to be able to signal the root task scope and perform this in a single call. We also need to set timeouts on shutting down individual task scopes and the entire tree, before moving to a “forced” shtudown.
aiohttp partially implements a concept like this by separating shutdown
/ cleanup
signal handlers and context managers. (ref: Web Server Advanced — aiohttp 3.9.5 documentation) I believe this should be expanded and generalized to structure all asyncio server apps.
A handling interface for continuous stream of results and errors
Currently, we only have the global fallback exception handler set for the entire event loop: loop.set_exception_handler()
For long-running task scopes, we need to be able to set up different exception handlers by each scope.
Tasks in task scopes for server apps usually do not return results as their job is to reply back to the clients or complete a background work. Though, there may be other use cases to retrieve the results and exceptions in a structured manner like spawning a group of long-running run-to-completion jobs together.
Generator-based as_completed()
-style API could help here, but I’d like to hear more opinions from language experts about the abstraction/design of such streamed results and failures.
TODO: check if the 3.13’s asyncio.as_completed()
supports continuation upon exceptions.
Ensuring all directly/indirectly spawned tasks inside a task scope belong to their parent task scope
As another thread mentioned above reveals, to control the tasks indirectly spawned in 3rd party libraries with nested task scopes, we should be able to customize the task factory (low-level), have a task-scope-wide context variables, or let all indirectly spawned tasks belong to the “current” task scope.
This feature is best implemented in the stdlib asyncio to control all 3rd-party libraries, but I think it may be possible to implement as a task factory. The problem is that it is difficult to register and compose multiple different task factories in a single asyncio loop. (e.g., combining aiomonitor) This leads to another discussion like Request: Can we get a c-api hook into PyContext_Enter and PyContext_Exit - #5 by brettlangdon, or having finer-grained hooks on the task lifecycle.
This will enable us to keep track of the tasks in a more structured way and provide more useful statistics and telemetry of asyncio applications by categorizing tasks. The current asyncio.all_tasks()
is too “inclusive”.
I’d like to hear other async library author’s opinions about making this a mandatory default or an optional flag, and potential breakage that could happen.