Open whatamithinking opened 1 year ago
Here is a python PoC. All the code for asyncio PyTask had to be copied in order to override __step and references to it.
Can you please show the PoC code in the form of a draft PR (presumably modifying the Task class)? That would be easier to follow.
Is there anything I can do to move this along or do I just need to wait for someone to review it?
I was hoping that we could add real cancel scopes to asyncio. They are vastly more versatile, and should cover this use case very well. My concern here is that adding this will further complicate their eventual introduction.
I have no opinion on the proposed feature here (and probably never will, there are too many things), but @agronholm your mention of "real cancel scopes" sounds provocative. Can you perhaps open an issue (or a Discourse thread) explaining what you want there? Without pointing to a 16-page blog post by NJS please, I am unable to concentrate long enough to read those. :-)
Sorry, I didn't mean to sound provocative. I intended to get around to this in the coming months when I had more time, but if this is going to be worked on in the near future, then I would like to get involved rather than seeing the work proceed in a direction I see as unfavorable. I'll make a discourse post and link it here.
For anyone coming here looking for how to do this with asyncio
currently, here's an implementation I came up with. It might not work 100% of the time and may have unintended consequences, but in most cases it seems to work as expected:
import asyncio
from contextlib import asynccontextmanager
@asynccontextmanager
async def cancel_shield():
task = asyncio.current_task()
cancel_messages: list[str | None] = []
task.cancel = lambda msg: cancel_messages.append(msg)
task.uncancel = lambda: cancel_messages.pop()
try:
yield
finally:
del task.cancel
del task.uncancel
for msg in cancel_messages:
task.cancel(msg)
Example usage:
async def work():
async with cancel_shield():
await asyncio.sleep(5)
print("work done")
async def main():
task = asyncio.create_task(work())
await asyncio.sleep(1)
task.cancel()
await task
asyncio.run(main())
I apologize for taking such a long time to address this. I had a long talk with @gvanrossum at PyCon US two days ago on this very subject. I pitched the idea of adding Trio-style cancel scopes with opt-in level cancellation, but he was understandably hesitant to add new features/paradigms to asyncio given how it's already hard to maintain.
As a compromise, we came to an agreement that I could design some new low-level machinery to asyncio that would make it less painful for third party libraries (like AnyIO) to implement cancel scopes. AnyIO already has a cancel scope implementation on top of asyncio, but it employs horrible hacks to achieve that, and there are outstanding bugs in that implementation. I don't yet know what that low-level machinery will look like, but once I have some proof-of-concept code, I will notify you here.
Meanwhile, would you mind taking a look at AnyIO if you haven't already, and letting me know if it helps your use case, or if not, what I could do to get you the rest of the way there?
Feature or enhancement
Add a
shield_scope
context manager which can shield a block of code from cancellation and block waiting until it completes. A pending cancellation for a task would either be raised during the next non-shielded await or at the end of the task. Shields are task-local and are not be copied into child tasks, to prevent breaking the behavior of child tasks.Pitch
The current shielding mechanism,
asyncio.shield
works by wrapping one future in another so the cancellation applies to the outer future while the inner future continues to run. While this solution is effective in protecting the awaitable you pass in, control flow becomes muddled (the exception bubbles up while this future is now running detached in the background) and the code the shield covers often has to grow to encompass other resources to avoid control flow issues, limiting the value of the cancellation features of asyncio.The simplest current workaround to maintaining control flow using
asyncio.shield
seems to be to wrap up the coroutine in a task and wait on that after the cancellation occurs. This solution adds a lot of overhead (Task object + loop cycle until task can start) to shielded functions, which can add up quickly in hot code paths. If shielded functions are nested, where to re-raise the cancellation also becomes a challenge requiring contextual information to know if we are still inside a coroutine which is shielded.The following example hopefully provides a more concrete example of how a shield scope could more efficiently solve this shielding / control flow need and be easier to work with.
The following shows how a
shield_scope
might be used. If the same code were run withasyncio.shield
wrapping the publish coroutines, theresource_lock
acquired bycreate_resource
would be released beforepublish
completed and thedelete_resource
coroutine would finish publishing before thecreate_resource
coroutine.Previous discussion
Linked PRs