Repository navigation
Implement asyncio.timeout() context manager #90927
Description
Activity
Now that TaskGroup is merged (see bpo-46752) we might consider adding some form of cancel scopes (another Trio idea).
There's a sensible implementation we could use as a starting point in @asvetlov's async-timeout package (https://github.com/aio-libs/async-timeout).
- added3.11only security fixesonly security fixestype-featureA feature request or enhancementA feature request or enhancement
on Feb 16, 2022 I've essentially forked
async-timeout(a very good library) into Quattro cancel scopes: https://github.com/Tinche/quattro/blob/main/src/quattro/cancelscope.py.The differences are:
- the API follows Trio, so instead of
timeoutyou'd usefail_afterormove_on_after - instead of
async with timeout, you use a normal context managerwith fail_after. The Trio folks think this is important (less suspension points, less race conditions) and I agree - it's somewhat composable (as much as possible under asyncio), each scope knows if the CancelError is meant for it or should be propagated further. This is implemented by using the CancelError message to carry a nonce. This only works on 3.9+, but here that's not a problem
- small deadline adjustment differences, I use a setter on the deadline instead of
updateandshift - it's fully type annotated, but so is Andrew's
Let me know if this sounds interesting.
- the API follows Trio, so instead of
Oh, and Trio's
current_effective_deadlineis also in.Sure, we should create the best possible solution.
We have no CI in the stdlib that checks type annotations, so those should probably be moved to a stub file in typeshed. (Ditto for asyncio taskgroups.py.)
Using the new .cancelling()/.uncancel() API added to Task you might be able to avoid hacks using the cancel msg (check how it's used in the new TaskGroup).
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:
- 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)
- Adding callbacks to continuously cancel tasks that yield inside a cancelled scope (con: ugly; potentially extra overhead?)
- 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.
Alex, the goal here is not to replicate every Trio feature or behavior. For example I don't think that asyncio is likely to get level cancellation in 3.11, but we can certainly see if we can do something about some of the edge cases you mention, like the case of a task that is cancelled before it has started running, where you say that the task should be allowed to run until its first await.
It would be nice to have a native asyncio example that demonstrates this, so we have a concrete goal.
I am thinking it is something like this:
async def send_from_open_file(f, s): data = f.read() f.close() await s.send(data) async def send_filename(filename, s): f = open(filename) t = asyncio.create_task(send_from_open_file(f, s)) t.cancel() await asyncio.sleep(1)
This is an interesting edge case and I can see why you'd rather see this run until the
await s.send(data)line. The question is, can we do that without breaking other promises implicit or explicit? (Just because the docs don't mention some behavior that doesn't mean we can change it. We have to consider what happens to actual real world code.)I don't even know if this would be easy to change if we decided it was a good change. Thoughts? (Possibly this discussion belongs in a new issue, since it's not directly related to adding cancel scopes.)
I'm not trying to argue that asyncio should be changed to have level cancellation or even cancel scopes as built-in (at this point), but expanding the low level API to make implementing these features possible in third party libraries without the awkward hacks we have now.
As for async-timeout, it suffers from the same problem as AnyIO and Quattro: that cancellations of the entire task can be inadvertently swallowed by the async context manager in edge cases. I hadn't even thought of the possibility of this happening until one of AnyIO's users reported just such a problem: agronholm/anyio#374
I just couldn't think of any way to correctly support such things without at least _some_ changes to the task cancellation behavior, and allowing .cancel() to update the cancel message seemed like the least invasive option. I'm all ears if someone has a better solution.
OK.
-
Please have a look at how .cancelling()/.uncancel() works (there's an example of it in TaskGroup) to solve that particular problem without using the cancel message.
-
Suppose you could make (backwards compatible) changes to asyncio. What would you do? 3.11 feature freeze (aka beta 1) is still a few months away (late May) so now's the time to get your wishes in.
-
Thanks, I will take a look at .uncancel() and .cancelling(). I saw that work happening in my email feed but couldn't figure out right away how it helped, but I will definitely look into the new TaskGroup code to see how it's used there and will get back to you after that.
81 remaining items
Sorry to hear it. Get well soon!
@pablogsal This is just lacking documentation. Does that qualify for it to be a (deferred) release blocker? The lack of documentation doesn't affect stability!
@pablogsal This is just lacking documentation. Does that qualify for it to be a (deferred) release blocker? The lack of documentation doesn't affect stability!
Yes, there is no problem with this one. This can be left as a deferred blocker and will not affect any betas or RCs 👍
Though I may have missed critical timing, I'd like to suggest to add ability to customize
msgwhen wecancel()the task, so that user codes can use it to distinguish whether the cancellation is triggered by timeout or other means, as in aio-libs-abandoned/async-timeout#320.Thanks to
cancelling()anduncancel(), we don't have to usemsgas a hack, but I believe it would be useful to leave it a customizable value for the API users since now we can use it for other purposes.It would be nice if we have this in Python 3.12 if it's not possible to add this option in 3.11.
Can’t happen in 3.11 due to the feature freeze at 3.11b1, and in fact we decided to deprecate the cancel message. There’s some discussion about it (maybe in this issue, maybe in a linked one).
Can’t happen in 3.11 due to the feature freeze at 3.11b1, and in fact we decided to deprecate the cancel message. There’s some discussion about it (maybe in this issue, maybe in a linked one).
I totally understand now the 3.11 features are frozen. I just want to note that using cancel messages sometimes greatly helps me to debug and understand what's happening when writing asyncio-based libraries. Of course, it is also easy to abuse. 😉
You should probably start a new discussion if you really want to revert the deprecation of the message parameter. E.g. on discuss.python.org.
Reacted by Joongi KimI've created a thread in discuss.python.org!
I've started on the docs. I'll be using the docs of the
async-timeout,anyio,trioandquattropackages as references, I guess.Where should I put these docs? I'm leaning towards a new section in
asyncio-task.rst.We might want to spin out docs on cancellation to a separate page in the future, since I feel like very few people understand the implications of it, and it's (in my opinion) the second most important asyncio superpower.
I agree it belongs in
asyncio-task.rst. It's perhaps most closely related towait_for, so possibly in the same section (on top)? We're not deprecatingwait_for, but we are trying to steer people in the direction of the new context managers.Eventually we may have an "explanation" about cancellation (using the Diataxis term), but that seems a separate doc change.
How about this for a start: Tinche@63221c9
I have a very small section on task cancellation, and the timeout context managers below, but before
wait_for.I feel like a bunch of sections (including the new TaskGroup section) keep mentioning cancellation, so we should at least introduce it fairly quickly.
And now that the docs have landed this issue can be laid to rest.
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsDone
- StatusShow more project fieldsDone
Note: these values reflect the state of the issue at the time it was migrated and might not reflect the current state.
Show more details
GitHub fields:
bugs.python.org fields: