Alex Grönholm <alex.gronh...@nextday.fi> added the comment:

A brief explanation of cancel scopes for the uninitiated: A cancel scope can 
enclose just a part of a coroutine function, or an entire group of tasks. They 
can be nested within each other (by using them as context managers), and marked 
as shielded from cancellation, which means cancellation won't be propagated 
(i.e. raised in the coroutine function) from a cancelled outer scope until 
either the inner scope's shielding is disabled or the inner scope is exited or 
cancelled directly.

The fundamental problem in implementing these on top of asyncio is that native 
task cancellation can throw a wrench in these gears. Since a cancel scope works 
by catching a cancellation error and then (potentially) allowing the coroutine 
to proceed, it would have to know, when catching a cancellation error, if the 
cancellation error was targeted at a cancel scope or the task itself. A 
workaround for this, made possible in Python 3.9, is to (ab)use cancellation 
messages to include the ID of the target cancel scope. This only solves half of 
the problem, however. If the task is already pending a cancellation targeted at 
a cancel scope, the task itself cannot be cancelled anymore since calling 
cancel() again on the task is a no-op. This would be solved by updating the 
cancel message on the second call. The docs don't say anything about the 
behavior on the second call, so it's not strictly speaking a change in 
documented behavior.

Then, on the subject of level cancellation: level cancellation builds upon 
cancel scopes and changes cancellation behavior so that whenever a task yields 
while a cancelled cancel scope is in effect, it gets hit with a CancelledError 
every time, as opposed to just once in asyncio's "edge" style cancellation. 
Another very important difference is that with level cancellation, even a task 
that starts within a cancelled scope gets to run up until the first yield 
point. This gives it an opportunity to clean up any resources it was given 
ownership of (a connected socket in a socket server is a common, practical 
example of this).

This is what the asyncio documentation states about Task.cancel():

"This arranges for a CancelledError exception to be thrown into the wrapped 
coroutine on the next cycle of the event loop.

The coroutine then has a chance to clean up or even deny the request by 
suppressing the exception with a try … … except CancelledError … finally block. 
Therefore, unlike Future.cancel(), Task.cancel() does not guarantee that the 
Task will be cancelled, although suppressing cancellation completely is not 
common and is actively discouraged."

This is, however, only true for a task that has started running. A Task that 
gets cancelled before even entering the coroutine is silently dropped.

As asyncio does not allow for custom task instances without overriding the 
entire task factory, it leaves libraries like AnyIO some less desirable options 
for implementing level cancellation:

1. Implementing a parallel task system using low level synchronous callbacks 
(con: such tasks won't show up in asyncio.all_tasks() or work with third party 
debugging tools)
2. Adding callbacks to continuously cancel tasks that yield inside a cancelled 
scope (con: ugly; potentially extra overhead?)
3. Adding a wrapper for the task that acts as a "controller" (con: adds an 
extra visible stack frame, messes with the default task name)

Having low level machinery for injecting a custom Task instance to the event 
loop would probably solve this problem.

----------
nosy: +alex.gronholm -tinchester

_______________________________________
Python tracker <rep...@bugs.python.org>
<https://bugs.python.org/issue46771>
_______________________________________
_______________________________________________
Python-bugs-list mailing list
Unsubscribe: 
https://mail.python.org/mailman/options/python-bugs-list/archive%40mail-archive.com

Reply via email to