New research project: Cancel scopes and level cancellation on asyncio

I have a new research project, called “asyncbis” here. The purpose of this project is to determine how and if level cancellation (as opposed to the current “edge” cancellation) and full-fledged cancel scopes could be introduced to asyncio, and how badly third party asyncio projects would break with these changes. The cancellation model is intended to conform to that of Trio and AnyIO.

Altered behavior

First and foremost, this modifies task spawning so that all tasks (except for the “root” task, spawned automatically) belong to a task group. If any such “background” task raises an exception, it will cause the root task group to be cancelled, and the exception propagated to the run() caller. If this is behavior is unwanted, exceptions should be caught and handled in the target coroutine directly.

Task groups now have a cancel() method which sets the task group’s cancel scope into a cancelled state. Tasks can still be spawned, but they will raise CancelledError at the first yield point, and any yield point unless inside a shielded cancel scope.

The existing shield() function was also modified to run the target awaitable directly in the host task under a shielded cancel scope, rather than creating a separate task which could be subject to cancellation if the event loop is shut down. This has the effect of also protecting the host task from cancellation during the call.

Python 3.11 introduced the timeout() and timeout_at() context managers to asyncio. These are essentially cancel scopes without level cancellation or shielding. This project adds level cancellation and shielding capabilities to said context managers.

Shutdown behavior was also altered so that the root task group is cancelled as a result of the first SIGINT or SIGTERM (which is now handled similarly), rather than cancelling all tasks directly. This allows tasks, and entire task groups a chance to react to the cancellation by performing a finalization step inside a shielded cancel scope. With the second such signal to arrive, however, it performs a hard shutdown, as before.

Future directions

If the initial, “research” phase is deemed successful, this could be spun into a full-fledged, alternate implementation. Building these features on top of rsloop could be an option, as it looks like a promising new alternative event loop implementation.

An AnyIO backend for this is also on the roadmap.

4 Likes
  • All tasks (except for the root task which runs all other tasks) belong to task groups(create_task() creates tasks in the root task group)
  • If a task raises an exception, it propagates to the parent task
  • If the root task group receives an exception, the event loop is shut down and the exception is propagated to the caller of run()

These points are a non-starter. I’m in favor of making there be an event-loop scoped way of managing background tasks, but treating any exception from asyncio.create_task as something to shutdown the event loop isn’t practical, flies in the face of what users have directly asked for, and it’s highly opinionated in a way that conflicts with some existing valid code.

See this thread for some discussion about ways to scope create_task that don’t break existing intentional use with known possible failing: Pain point in asyncio: Potentially-failing tasks

1 Like

In reply to @mikeshardmind’s post

This comes of as quite aggressive to me for something explicitly labeled as a research-project.
As presented I get the main point here being exploring the impact of general level-cancellation semantics rather than mainly a solution to “lost” exceptions that seem to be the main focus in the linked thread.

I’ve read through that thread and my main thoughts are that if people need tasks that just log their errors that’s trivial to do on a task basis with a wrapper that suppresses and logs the exception. If common enough it could even be provided as a flag to create_task so I have a hard time seeing that as a non-starters at this stage.

Regarding any exception shutting down the main loop that’s not what’s guaranteed here. Any task will shut down its task-group which will exit with an exception after all its tasks are finished. You can catch that and it’s only if an exception bubbles out to the outermost task-group (the loop) that everything is shut-down.

Maintaining that the reasonable default should be to log and discard errors for any called function feels very much against the principle of “Errors should never pass silently”. That this has been the case for previous api’s like for both threads and tasks feels much more like a pragmatic compromise done due to no reasonable way to do otherwise existed (due to lack of task_groups/scopes) than a conscious good design desision.

Edit: Added explicit reply

1 Like

Can you elaborate on why these points are non-starters in a research project? One of the reasons I made this was to gauge whether Trio users would be more comfortable with asyncio if it had more Trio-like semantics. In asyncbis, create_task() works like Trio’s spawn_system_task() which frankly I don’t think I’ve ever seen anyone use.

As for @tapetersen, I agree 100% on every point.

I have yet to begin testing against third party projects, but once I do, the results ought to be illuminating.

1 Like

I mean, you’re presenting it as if the goal is to make this the behavior in asyncio as a result of the research:

I don’t think it’s viable in that context at all. If this was only presented as yet another alternative library and not as exploring potential asyncio behavior, that’s a whole different story, but people have built plenty of real world code with the valid assumption that they can use create_task to create a background task that doesn’t cancel unrelated tasks.

Not sure why you’re saying this. Making tasks created via create_task() not fail the root task group can be done trivially without sacrificing cancel scopes or level cancellation, and could even be toggled via a switch. I simply chose to do it this way in asyncbis because I want to find out how many projects could still function without it. Trio proved that it’s an entirely valid approach.