The AnyIO library is designed to give a unified interface between asyncio and Trio. It has a TaskGroup
class mimicking the Trio Nursery
class. Indeed, if using Trio as the backend, it is just an alias for trio.Nursery
; but, if using asyncio as the backend, it is a complete reimplementation.
But recent versions of Python (3.11 onwards) provide their own asyncio.Taskgroup
class.
What is the difference between the two?
The AnyIO documentation lists a few differences between the two. But it does not include the really big one: level-based cancellation vs edge-based cancellation.
AnyIO task groups (and Trio nurseries) use level-based cancellation. That means that, once the task group is cancelled (either manually by calling
tg.cancel_scope.cancel()
or implicitly because a task within has raised an exception), all further calls within that task group will throw a cancellation exception (unless shielded) which will be caught by the task group. Here is an example:This will produce the following output:
Notice how the same task in the group got a cancellation exception twice, because after the first one was dispatched we tried to await something else.
In contrast, asyncio task groups use edge-based cancellation. This means that any currently active tasks (which must all be sitting in an
await
) will receive a cancellation exception, but then any future calls will continue as usual. Consider this example (reusing the utility functions from the snippet before):This will produce output like this:
Note how, in the
finally:
block indouble_wait()
, the second sleep was allowed to run to completion rather than re-raising the cancellation exception.(By the way, this explain the final entry in the list of differences linked to at the strat of the answer: asyncio does not allow tasks to be started in a task group that has been cancelled. That's because there's no way that this task would ever "find out" about the cancellation. In contrast, anyio does allow tasks to be started in a task group that has been cancelled, because it will still receive a cancellation exception as soon as it gets to its first (non-shielded) await.)
Edge-based cancellation is potentially a lot more fragile because if some utility function within a task accidentally suppresses a cancellation exception then the rest of the task will continue indefinitely, unaware that it is operating in a context that ought to be cancelled.
Here is another (related) difference between them. AnyIO task groups (and Trio nurseries) will immediately recursively cancel all tasks within them. In asyncio task groups, this will eventually happen (so long as nothing suppresses cancellation exceptions) but tasks within nested task groups will only be cancelled when that inner task group comes to a close.
That's all a bit abstract but it's clear from an example. Consider this snippet:
It doesn't matter exactly what
foo()
is, but imagine that it takes a while to run and also takes a while to cancel (because it catchesasyncio.CancelledError
, sleeps a bit, and then re-raises it). So, whencancel_soon()
raises an exception, all 5 calls tofoo()
are still running.outer_tg
gets the exception, it will initially only cancelfoo(1)
,foo(2)
andfoo(5)
. Only whenfoo(5)
completes (e.g. by allowingasyncio.CancelledError
to escape) andinner_tg
gets to the end of its block does it cancelfoo(3)
andfoo(4)
. Iffoo(5)
suppresses the cancellation exception then they won't be cancelled at all.outer_tg
gets the exception, all the calls tofoo()
are cancelled immediately.