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.

6 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.

1 Like

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.

2 Likes

Following the “one-problem-at-a-time” philosophy, I’d like to make a comparison table of the two different approaches: anyio/asyncbis vs. aiotools.

As a part of PyCon KR 2026 Sprint, I could have a brief chance to review @agronholm’s experiment.

Similarity and Difference

Experiment:
A prominent difference is observed with a test case in aiotools: test_taskscope_shielded_nested_4, which is a test exercising five nested scopes within a single task whose shield flags are [off, on, off, off, on], with an external task.cancel() signal arriving inside the innermost scope.

Link to Gist: I ported it to asyncbis (TaskScope(shield=X)TaskGroup() + cancel_scope.shield = X) and ran both with identical timings.

Expected:
Every block and child task up to the outermost shield completes, then CancelledError fires at that boundary and cancels the rest.

Findings:

  1. :raising_hands: The block-level protection semantics are equivalent, if the cancellation is addressed to the task’s root cancel scope on asyncbis: identical block trace, identical set of completed child tasks, task ends cancelled. This is a good news for convergence between the two models.
  2. :broken_heart: Task.cancel(), where the entry point existing asyncio code actually uses, diverges completely.
    • asyncbis cancels the innermost currently-active scope; a shield doesn’t protect
      a scope from being cancelled directly (it only blocks parent→child propagation, per
      Trio semantics), so the innermost shielded block dies immediately, the CancelledError
      is absorbed at that block’s boundary, and the host task itself then runs to completion
      successfully
      (task.cancelled() is False).
    • aiotools defers the same call to the outermost shield’s exit, and the host task ends
      up cancelled.
  3. :broken_heart: Sibling timing differs: level propagation cancels all unshielded branches the
    moment the cancel arrives (an unshielded sibling child died at t≈0.03s in my run);
    deferred delivery keeps them running until replay at the outermost shield’s exit
    (t≈1.42s). Same final assertions here, observably different when siblings have side
    effects.

Updating the stdlib

Assuming that we are going to update the stdlib, here are the minimal changes to be applied when taking either approach:

Aspect Level model in stdlib (asyncbis-style) Deferred-delivery model in stdlib (aiotools-style)
Task.cancel() semantics changes meaning (scope-addressed); existing callers break subtly, incl. the shield-pierce-then-absorb behavior above unchanged; only delivery is deferred while a shield block is active
3.11 cancelling()/uncancel() protocol superseded by scope identity preserved and extended (requests counted at request time, as today)
Required Task changes rewritten __step, per-task scope chain, custom task classes/factories disallowed a deferral counter consulted in Task.cancel() — generalizing the existing request-vs-delivery gap (_must_cancel)
asyncio.shield() replaced (detached → scope-based) untouched; a sync with shield CM added alongside
TaskGroup/timeout() internals reimplemented on scopes unchanged
Shield escape hatch runner-level only (second SIGINT → hard shutdown) scope-level, already in the library (TaskScope.abort()/aclose()); although it’s not part of the stdlib API
Opt-in no — global semantic change yes — zero behavior change unless the new CM is used

Currently asyncbis is an early single-commit sketch, so some of findings may be artifacts rather than intent.

@agronholm, I’d like your read on whether innermost-scope-targeted Task.cancel() is the intended mapping, or whether a level-cancellation asyncio should keep task-addressed cancel.

Why this matters for sequencing stdlib changes

Finding (2) is the crux: even a ground-up Task reimplementation couldn’t naturally
graft the existing task-addressed cancellation API (Task.cancel() and everything
built on task handles) onto a scope-addressed model.

This is not an argument against level cancellation as a destination. Like anyio and asyncbis, I’d like to position aiotools as an experiment/proving ground for future asyncio changes.

I’d like to highlight that:

  • We have an alternative worth to compare with: aiotools’s deferred-delivery model offers a smaller primitive and opt-in capability, preserving backward compatibility.
  • Either direction ultimately needs a stdlib change to shed its hacks: with a
    deferral primitive in Task, aiotools could drop its per-instance Task method
    patching, and anyio could shield host tasks exactly instead of retry-delivering.

Requesting Feedbacks

  1. asyncio.cancel_and_wait(task), resolving gh-103486. Pure addition; codifies counting-based attribution as the documented idiom. aiotools ships a tested implementation covering eager tasks and tasks that swallow cancellation.
  2. A cancel-delivery deferral counter on Task + a synchronous with asyncio.shielded(): context manager, resolving gh-99714. While the counter is nonzero, Task.cancel() records the request (counted immediately, exactly as today) without throwing into the coroutine; delivery happens at the outermost exit.
    • Open design points: interaction with an inner asyncio.timeout() (aiotools deliberately lets deadlines pierce the shield), and disposition of latched requests if the task or loop finishes first.

Does anyone see a backward-compat hazard in the deferral counter that I’m missing?

I’m not sure if you’re aware, but in the latest release, AnyIO’s task-spawning methods now return task handles which can be cancelled individually. This is done with a task-level cancel scope. You could look at that for reference, as asyncbis is mostly intended to mirror the same semantics.

I haven’t touched asyncbis in a while as I’ve been busy dealing with a barrage of AnyIO PRs.

1 Like