-
-
Notifications
You must be signed in to change notification settings - Fork 342
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[discussion] What to do about cancel scopes / nurseries inside generators? #264
Comments
Some discussion on python-ideas (scroll down to point 9): https://mail.python.org/pipermail/python-ideas/2017-August/046736.html |
Notes:
|
One idea for nurseries-in-generators is to provide an ergonomic way to get a context-managed generator. I wrote an example implementation up at https://gist.github.com/oremanj/772241665edea3ceb3183a4924aecc08 (it was originally intended to be a quick-and-dirty demo, but rather grew a life of its own...) I think something like this would likely be useful in a trio utilities library. It doesn't require any special support from trio or async_generator and is rather large/magical so I'm not sure it deserves to make it into trio itself, unless trio wants to use it. There are still notable limitations; for example, in the azip() example, if one of the inner slow_counters reaches its deadline and is cancelled, the whole azip() will be cancelled, because we had to enter the slow_counter contexts to get async iterables that could be passed to the azip() context. But if the limitations are acceptable, cancel scopes and nurseries that are attached to a generator in this fashion should be guaranteed to nest correctly / not mess up the cancel stack. |
Thinking about this a bit more... PEP 568, if implemented, would make it possible to assign a reasonable semantics to cancel scopes with PEP 568 would not make it possible to assign a reasonable semantics to a nursery with a yield inside it – that's intrinsically impossible if we want to keep the guarantee that exceptions in background tasks are propagated promptly, because if a background task crashes while the generator-holding-the-nursery is suspended, what can you do? There's no live stack frame to propagate it into. If we were willing to relax the guarantee to just, exceptions are propagated eventually, and PEP 533 were implemented, then we could allow So, maybe we want to we forbid One more thing to consider: what if we decide to disallow If that's what we want to do, then we don't need to tie to it PEP 568-style nested contexts. The reason why tying them together is attractive is that PEP 568-style nested contexts need to have a special case to handle I guess what we'd want is: a way to set a flag on a caller's frame that makes it an error to attempt to SummaryOur options are:
PEP 533 is still how Python ought to work, but may not be the right hill to die on... |
Actually, that's not quite right... class CM1:
def __enter__(self):
self._cm2 = CM2()
self._cm2.__enter__()
class CM2:
def __enter__(self):
disallow_yield()
with CM1():
yield # should be an error So we really would want something like the PEP 568 semantics... like a stack of bools (or rather ints, to allow inc/dec, so Eh, we have other stacks in the interpreter state, like the exception stack, it wouldn't be too weird to add another. And that way of formulating it wouldn't be a problem for PyPy. |
Example of a case where having the disallow-yield state be stored in the |
Frightening thought: we did a tremendous amount of work in python-trio/pytest-trio#55 to allow pytest-trio fixtures to The frightening thought is: well, why did we do all that work? It was because we decided it's not reasonable to expect people to know whether a random async context manager like |
A small problem with PEP 568: the way But in the time since I've written that, I've learned more about all the ways this is an unreliable thing to try to do, in a world where people love their decorators. It's fine of course if def noop_wrap(fn):
def wrapped(*args, **kwargs):
return fn(*args, **kwargs)
return wrapped
@contextmanager
@noop_wrap
def agen_fn():
... because Instead, the special case should be triggered by an optional argument passed to |
Instead of a stack of bools or ints, maybe it should be a stack of str objects describingwhy |
A few more notes based on further thought:
|
From what I've read in this comment, it seems like the idea of:
is out of the picture. I was curious about that so I've been experimenting a bit and I came up with this write-up explaining why it might conceptually make sense. I've tried to find and read most of the related posts in issue #264 and #638 but I might have missed the explanation I was looking for. Anyway, I'm curious to hear what you guys think about it and I hope I'm not missing something crucial. |
There's an initial proposal for making it possible to disallow |
@vxgmichel The big issue with that approach for me is that I don't understand it :-). Basically, what you say in the "Limitations" section at the end. In code like this: async with aitercontext(agen()) as safe_agen:
async for item in safe_agen:
... # block 1
... # block 2 I find it really hard to reason about what happens with one of the two Maybe it could technically work? But I have no idea how to explain it to beginners. |
I haven't thought about it in month, so it was quite a struggle to get back into it too 😅
I think block 1 is fine, I would just reason about it the same way we reason about async context manager. This way, it doesn't seem absurd (to me at least) that agen might have to deal with an exception raised by its consumer. It acts like the consumer notifying the producing generator that it should stop, clean up and return. Much like an async context manager, it is not suppose to yield new values after an exception has been raised (or an
Block 2 and block 0 (in between the
Interesting! That makes me realize that the generators I described in the write-up should be treated as a specific type of async generator. In the proposal there is the following example about async context manager: @asynccontextmanager
async def open_websocket(...):
async with open_nursery():
yield Websocket(...) # This yield is OK We could also imagine to allow yield statements for "producers" (I couldn't find a better term): @asyncproducer
async def read_websocket(...):
async with open_nursery():
async for message in Websocket(...):
yield message # This yield is OK Assuming PEP 533 and async for message in read_websocket(...):
print(message)
break # or raise
# The producer has been properly shutdown by now |
I ran into part of this issue in a simple sync case, and wondered about handling it in a linter by requiring try:
yield
finally:
... |
I thought I'd link here the latest idea from @njsmith to fix this issue in CPython: Preventing |
@vxgmichel There's a closely related discussion in #638, starting from this comment: #638 (comment) There we're talking about running generators as a separate task that pushes values into a channel, and the main task iterates over this channel instead of iterating over the agen directly. I guess if you really think about it carefully, this is... probably equivalent to your suggestion, or mostly-equivalent, in terms of the final behavior? If the background task crashes, this cancels the looping code, or vice-versa, etc. But I find the generator-as-separate-task version a lot easier to think about, because it reuses normal trio concepts like tasks and channels. |
@apnewberry I don't think that issue is the same as this one, though I could be missing something. The issue here is all about |
Yes, I actually commented on the corresponding gist (that wasn't a very good idea since there's no notification system for gist comments).
I would argue that both approaches are useful but not equivalent. The channel-based approach is conceptually similar to go-style generators with the producer and consumer tasks running concurrently, occasionally handshaking over a channel. On the other hand, the native generator approach is running a single task, executing either producer code or consumer code. In the first case, an unused item might get lost in the channel when the consumer decides it had enough. In the second case, an item is only produced when the consumer asks for it, so no item is lost. This might or might not be important depending on the use case. |
That's true, even with an unbuffered channel the producer does start on producing the next object as soon as the previous one is consumed. |
I guess there's no technical reason you couldn't have a channel with a buffer size of "-1", where |
@njsmith That would still produce a possibly-lost first item. IMHO for lock-step non-parallelism you don't need a queue, you need a simple procedure call to the producer. |
@smurfix Yeah, you also need some way to wait for that first Of course, I was kind of hoping that we could get rid of This would also have some weird edge cases. If the consumer cancels their Weirder: if the producer sets a timeout, it could leak out and cause the harness's call to All in all it might turn out that it's just not worth going to any heroic lengths to try to make the background-generator style match the inline-generator style. They're not quite the same, but in practice the simple version of background generators might work fine. |
When exiting an outer cancel scope before some inner cancel scope nested inside it, Trio would previously raise a confusing chain of internal errors. One common source of this misbehavior was an async generator that yielded within a cancel scope. Now the mis-nesting condition will be noticed and in many cases the user will receive a single RuntimeError with a useful traceback. This is a best-effort feature, and it's still possible to fool Trio if you try hard enough, such as by closing a nursery's cancel scope from within some child task of the nursery. Fixes python-trio#882. Relevant to python-trio#1056, python-trio#264.
This is happening to me, and is surprising and unwanted. I'm implementing the guts of a channel in an async generator. The generator's control flow is its state, as opposed to using a state machine. If someone calling The only way around this I can see so far is to wrap every Or I can implement a state machine, but that would seem to defeat the point of async. Any suggestions please? This isn't exactly a cancel scope inside a generator so is perhaps off-topic, but your statement is the closest existing discussion on my issue that I can find. I can create a separate issue if you prefer? |
I'm assuming by By far the easiest approach is to push the async generator logic into a background task which communicates the values to be yielded over a memory channel. That way, you can cancel waiting for the value without cancelling obtaining the value. There's an adapter I wrote in #638 that lets you do this still with async generator syntax: #638 (comment) The remainder of this comment is for the case where you really don't want to do that, for whatever reason. Be warned, it gets gnarly. :-) If you want the semantics where cancelling the generator will cause it to resume whatever async operation it was working on when it was cancelled, you have to write it yourself, in a way that contains a But... there is an evil brittle hack that you can use to suspend an async generator from within an async function that it calls:
Then If you don't like evil brittle hacks, you can use the Whichever approach you take, you can now write an abstraction like you've described:
This is untested. If you wind up using it, I'd be curious for feedback! |
So here's a surprising thing that can happen in trio right now: if you have a generator (or async generator, doesn't matter) that yields inside a cancel scope, then the cancel scope remains in effect until you resume the generator and exit it.
For context managers, this is actually what you want, and we make use of this in lots of places, e.g.:
So here we have: first
fail_at
runs for a bit, and it starts a cancel scope, and then it yields. And then the XX code runs with the cancel scope in effect, even thoughfailt_at
is not on the stack. And thenfail_at
is resumed, and it exits the cancel scope. A little confusing when you think about the details, but basically this is safe and useful and ultimately does what you'd expect.However, in general this can cause strange behavior, and also crashes:
Notice that even after we leave the loop and abandon the generator, the cancel scope is still in effect... and then the program crashes because trio detects that the cancel scope stack has become inconsistent (it tries to pop a scope that isn't at the top of the stack).
This all also applies to using nurseries inside async generators, because nurseries have an implicit cancel scope.
There are also some more generic issues with cleaning up (async) generators that call trio APIs after yielding, but that's another issue (specifically, issue #265). The cancel scope leakage causes problems even while the generator is alive, and even if we had PEP 533 then this would still be an issue.
Really the ideal solution here would be to error out if someone tries to
yield
through a cancel scope, IFF they're not implementing a context manager.I don't think there's any way to do this though without PEP 521, and even then we'd have to figure out somehow whether this particular generator is implementing a context manager. (I guess some annoying thread-local thing could work -- e.g. make
with trio.this_is_a_context_manager: ...
which sets some internal flag that disables the PEP 521-based checks.)I don't currently have any other ideas how to improve this, except to put some prominent warning in the documentation.
The text was updated successfully, but these errors were encountered: