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:
import anyio
async def cancel_soon():
print("cancel_soon starting")
await anyio.sleep(0.5)
raise RuntimeError
async def wait_and_print(i):
print(f"Wait {i} starting")
try:
await anyio.sleep(1)
print(f"Wait {i} done")
except BaseException as e:
print(f"Wait {i} except: {e!r}")
raise
async def double_wait():
try:
await wait_and_print(1)
finally:
await wait_and_print(2)
async def anyio_group_test():
try:
async with anyio.create_task_group() as tg:
tg.start_soon(double_wait)
tg.start_soon(cancel_soon)
except Exception as e:
print(f"test caught: {e!r}")
anyio.run(anyio_group_test)
This will produce the following output:
Wait 1 starting
cancel_soon starting
Wait 1 except: CancelledError('Cancelled by cancel scope 1dea0d2a2d0')
Wait 2 starting
Wait 2 except: CancelledError('Cancelled by cancel scope 1dea0d2a2d0')
test caught: ExceptionGroup('unhandled errors in a TaskGroup', [RuntimeError()])
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):
import asyncio
async def asyncio_group_test():
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(double_wait())
tg.create_task(cancel_soon())
except Exception as e:
print(f"test caught: {e!r}")
asyncio.run(asyncio_group_test())
This will produce output like this:
Wait 1 starting
cancel_soon starting
Wait 1 except: CancelledError()
Wait 2 starting
Wait 2 done
test caught: ExceptionGroup('unhandled errors in a TaskGroup', [RuntimeError()])
Note how, in the finally:
block in double_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:
async def example():
async with asyncio.TaskGroup() as outer_tg:
outer_tg.create_task(foo(1))
outer_tg.create_task(foo(2))
outer_tg.create_task(cancel_soon())
async with asyncio.TaskGroup() as inner_tg:
inner_tg.create_task(foo(3))
inner_tg.create_task(foo(4))
await foo(5)
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 catches asyncio.CancelledError
, sleeps a bit, and then re-raises it). So, when cancel_soon()
raises an exception, all 5 calls to foo()
are still running.
- In asyncio, when
outer_tg
gets the exception, it will initially only cancel foo(1)
, foo(2)
and foo(5)
. Only when foo(5)
completes (e.g. by allowing asyncio.CancelledError
to escape) and inner_tg
gets to the end of its block does it cancel foo(3)
and foo(4)
. If foo(5)
suppresses the cancellation exception then they won't be cancelled at all.
- In the AnyIO and Trio versions of this code, when
outer_tg
gets the exception, all the calls to foo()
are cancelled immediately.