[Python-ideas] New PEP 550: Execution Context

Yury Selivanov yselivanov.ml at gmail.com
Sat Aug 12 16:58:12 EDT 2017


Nathaniel, Nick,

I'll reply only to point 9 in this email to split this threads into
manageable sub-threads.  I'll cover other points in later emails.

On Sat, Aug 12, 2017 at 3:54 AM, Nathaniel Smith <njs at pobox.com> wrote:
> 9. OK, my big question, about semantics.

FWIW I took me a good hour to fully understand what you are doing with
"fail_after" and what you want from PEP 550, and the actual associated
problems with generators :)

>
> The PEP's design is based on the assumption that all context-local
> state is scalar-like, and contexts split but never join. But there are
> some cases where this isn't true, in particular for values that have
> "stack-like" semantics. These are terms I just made up, but let me
> give some examples. Python's sys.exc_info is one. Another I ran into
> recently is for trio's cancel scopes.

As you yourself show below, it's easy to implement stacks with the
proposed EC spec. A linked list will work good enough.

>
> So basically the background is, in trio you can wrap a context manager
> around any arbitrary chunk of code and then set a timeout or
> explicitly cancel that code. It's called a "cancel scope". These are
> fully nestable. Full details here:
> https://trio.readthedocs.io/en/latest/reference-core.html#cancellation-and-timeouts
>
> Currently, the implementation involves keeping a stack of cancel
> scopes in Task-local storage. This works fine for regular async code
> because when we switch Tasks, we also switch the cancel scope stack.
> But of course it falls apart for generators/async generators:
>
> async def agen():
>     with fail_after(10):  # 10 second timeout for finishing this block
>         await some_blocking_operation()
>         yield
>         await another_blocking_operation()
>
> async def caller():
>     with fail_after(20):
>         ag = agen()
>         await ag.__anext__()
>         # now that cancel scope is on the stack, even though we're not
>         # inside the context manager! this will not end well.
>         await some_blocking_operation()  # this might get cancelled
> when it shouldn't
>     # even if it doesn't, we'll crash here when exiting the context manager
>     # because we try to pop a cancel scope that isn't at the top of the stack
>
> So I was thinking about whether I could implement this using PEP 550.
> It requires some cleverness, but I could switch to representing the
> stack as a singly-linked list, and then snapshot it and pass it back
> to the coroutine runner every time I yield.

Right. So the task always knows the EC at the point of "yield". It can
then get the latest timeout from it and act accordingly if that yield
did not resume in time.  This should work.

> That would fix the case
> above. But, I think there's another case that's kind of a showstopper.
>
> async def agen():
>     await some_blocking_operation()
>     yield
>
> async def caller():
>     ag = agen()  # context is captured here
>     with fail_after(10):
>         await ag.__anext__()
>
> Currently this case works correctly: the timeout is applied to the
> __anext__ call, as you'd expect. But with PEP 550, it wouldn't work:
> the generator's timeouts would all be fixed when it was instantiated,
> and we wouldn't be able to detect that the second call has a timeout
> imposed on it. So that's a pretty nasty footgun. Any time you have
> code that's supposed to have a timeout applied, but in fact has no
> timeout applied, then that's a really serious bug -- it can lead to
> hangs, trivial DoS, pagers going off, etc.

As I tried to explain in my last email, I generally don't believe that
people would do this partial iteration with timeouts or other contexts
around it.  The only use case I can come up so far is implementing
some sort of receiver using an AG, and then "listening" on it through
"__anext__" calls.

But the case is interesting nevertheless, and maybe we can fix it
without relaxing any guarantees of the PEP.

The idea that I have is to allow linking of ExecutionContext (this is
similar in a way to what Nick proposed, but has a stricter semantics):

1. The internal ExecutionContext object will have a new "back" attribute.

2. For regular code and coroutines everything that is already in the
PEP will stay the same.

3. For generators and asynchronous generators, when a generator is
created, an empty ExecutionContext will be created for it, with its
"back" attribute pointing to the current EC.

4. The lookup function will be adjusted to to check the "EC.back" if
the key is not found in the current EC.

5. The max level of "back" chain will be 1.

6. When a generator is created inside another generator, it will
inherit another generator's EC. Because contexts are immutable this
should be OK.

7. When a coroutine is created inside an EC with a "back" link, it
will merge EC and EC.back in one new EC. Merge can be done very
efficiently for HAMT mappings which I believe we will end up using for
this anyways (an O(log32 N) operation).

An illustration of what it will allow:

def gen():
   yield
   with context(key='spam'):
       yield
   yield

g = gen()

context(key=1)
g.send(None)
# The code around first yield will see "key=1"

context(key=2)
g.send(None)
# The code around second yield will see "key=spam"

context(key=3)
g.send(None)
# The code around thrird yield will see "key=3"

Essentially, it makes generators "transparent" to the outside context
changes, but OTOH fully isolate their local context changes from the
outside world.

This should solve the "fail_after" over a generator case.

Nathaniel and Nick, what do you think?

Yury


More information about the Python-ideas mailing list