388 lines
13 KiB
Plaintext
388 lines
13 KiB
Plaintext
|
PEP: 521
|
|||
|
Title: Managing global context via 'with' blocks in generators and coroutines
|
|||
|
Version: $Revision$
|
|||
|
Last-Modified: $Date$
|
|||
|
Author: Nathaniel J. Smith <njs@pobox.com>
|
|||
|
Status: Deferred
|
|||
|
Type: Standards Track
|
|||
|
Content-Type: text/x-rst
|
|||
|
Created: 27-Apr-2015
|
|||
|
Python-Version: 3.6
|
|||
|
Post-History: 29-Apr-2015
|
|||
|
|
|||
|
|
|||
|
Abstract
|
|||
|
========
|
|||
|
|
|||
|
While we generally try to avoid global state when possible, there
|
|||
|
nonetheless exist a number of situations where it is agreed to be the
|
|||
|
best approach. In Python, the standard way of handling such cases is
|
|||
|
to store the global state in global or thread-local storage, and then
|
|||
|
use ``with`` blocks to limit modifications of this global state to a
|
|||
|
single dynamic scope. Examples where this pattern is used include the
|
|||
|
standard library's ``warnings.catch_warnings`` and
|
|||
|
``decimal.localcontext``, NumPy's ``numpy.errstate`` (which exposes
|
|||
|
the error-handling settings provided by the IEEE 754 floating point
|
|||
|
standard), and the handling of logging context or HTTP request context
|
|||
|
in many server application frameworks.
|
|||
|
|
|||
|
However, there is currently no ergonomic way to manage such local
|
|||
|
changes to global state when writing a generator or coroutine. For
|
|||
|
example, this code::
|
|||
|
|
|||
|
def f():
|
|||
|
with warnings.catch_warnings():
|
|||
|
for x in g():
|
|||
|
yield x
|
|||
|
|
|||
|
may or may not successfully catch warnings raised by ``g()``, and may
|
|||
|
or may not inadverdantly swallow warnings triggered elsewhere in the
|
|||
|
code. The context manager, which was intended to apply only to ``f``
|
|||
|
and its callees, ends up having a dynamic scope that encompasses
|
|||
|
arbitrary and unpredictable parts of its call\ **ers**. This problem
|
|||
|
becomes particularly acute when writing asynchronous code, where
|
|||
|
essentially all functions become coroutines.
|
|||
|
|
|||
|
Here, we propose to solve this problem by notifying context managers
|
|||
|
whenever execution is suspended or resumed within their scope,
|
|||
|
allowing them to restrict their effects appropriately.
|
|||
|
|
|||
|
|
|||
|
Specification
|
|||
|
=============
|
|||
|
|
|||
|
Two new, optional, methods are added to the context manager protocol:
|
|||
|
``__suspend__`` and ``__resume__``. If present, these methods will be
|
|||
|
called whenever a frame's execution is suspended or resumed from
|
|||
|
within the context of the ``with`` block.
|
|||
|
|
|||
|
More formally, consider the following code::
|
|||
|
|
|||
|
with EXPR as VAR:
|
|||
|
PARTIAL-BLOCK-1
|
|||
|
f((yield foo))
|
|||
|
PARTIAL-BLOCK-2
|
|||
|
|
|||
|
Currently this is equivalent to the following code copied from PEP 343::
|
|||
|
|
|||
|
mgr = (EXPR)
|
|||
|
exit = type(mgr).__exit__ # Not calling it yet
|
|||
|
value = type(mgr).__enter__(mgr)
|
|||
|
exc = True
|
|||
|
try:
|
|||
|
try:
|
|||
|
VAR = value # Only if "as VAR" is present
|
|||
|
PARTIAL-BLOCK-1
|
|||
|
f((yield foo))
|
|||
|
PARTIAL-BLOCK-2
|
|||
|
except:
|
|||
|
exc = False
|
|||
|
if not exit(mgr, *sys.exc_info()):
|
|||
|
raise
|
|||
|
finally:
|
|||
|
if exc:
|
|||
|
exit(mgr, None, None, None)
|
|||
|
|
|||
|
This PEP proposes to modify ``with`` block handling to instead become::
|
|||
|
|
|||
|
mgr = (EXPR)
|
|||
|
exit = type(mgr).__exit__ # Not calling it yet
|
|||
|
### --- NEW STUFF ---
|
|||
|
if the_block_contains_yield_points: # known statically at compile time
|
|||
|
suspend = getattr(type(mgr), "__suspend__", lambda: None)
|
|||
|
resume = getattr(type(mgr), "__resume__", lambda: None)
|
|||
|
### --- END OF NEW STUFF ---
|
|||
|
value = type(mgr).__enter__(mgr)
|
|||
|
exc = True
|
|||
|
try:
|
|||
|
try:
|
|||
|
VAR = value # Only if "as VAR" is present
|
|||
|
PARTIAL-BLOCK-1
|
|||
|
### --- NEW STUFF ---
|
|||
|
suspend()
|
|||
|
tmp = yield foo
|
|||
|
resume()
|
|||
|
f(tmp)
|
|||
|
### --- END OF NEW STUFF ---
|
|||
|
PARTIAL-BLOCK-2
|
|||
|
except:
|
|||
|
exc = False
|
|||
|
if not exit(mgr, *sys.exc_info()):
|
|||
|
raise
|
|||
|
finally:
|
|||
|
if exc:
|
|||
|
exit(mgr, None, None, None)
|
|||
|
|
|||
|
Analogous suspend/resume calls are also wrapped around the ``yield``
|
|||
|
points embedded inside the ``yield from``, ``await``, ``async with``,
|
|||
|
and ``async for`` constructs.
|
|||
|
|
|||
|
|
|||
|
Nested blocks
|
|||
|
-------------
|
|||
|
|
|||
|
Given this code::
|
|||
|
|
|||
|
def f():
|
|||
|
with OUTER:
|
|||
|
with INNER:
|
|||
|
yield VALUE
|
|||
|
|
|||
|
then we perform the following operations in the following sequence::
|
|||
|
|
|||
|
INNER.__suspend__()
|
|||
|
OUTER.__suspend__()
|
|||
|
yield VALUE
|
|||
|
OUTER.__resume__()
|
|||
|
INNER.__resume__()
|
|||
|
|
|||
|
Note that this ensures that the following is a valid refactoring::
|
|||
|
|
|||
|
def f():
|
|||
|
with OUTER:
|
|||
|
yield from g()
|
|||
|
|
|||
|
def g():
|
|||
|
with INNER
|
|||
|
yield VALUE
|
|||
|
|
|||
|
Similarly, ``with`` statements with multiple context managers suspend
|
|||
|
from right to left, and resume from left to right.
|
|||
|
|
|||
|
|
|||
|
Other changes
|
|||
|
-------------
|
|||
|
|
|||
|
``__suspend__`` and ``__resume__`` methods are added to
|
|||
|
``warnings.catch_warnings`` and ``decimal.localcontext``.
|
|||
|
|
|||
|
|
|||
|
Rationale
|
|||
|
=========
|
|||
|
|
|||
|
In the abstract, we gave an example of plausible but incorrect code::
|
|||
|
|
|||
|
def f():
|
|||
|
with warnings.catch_warnings():
|
|||
|
for x in g():
|
|||
|
yield x
|
|||
|
|
|||
|
To make this correct in current Python, we need to instead write
|
|||
|
something like::
|
|||
|
|
|||
|
def f():
|
|||
|
with warnings.catch_warnings():
|
|||
|
it = iter(g())
|
|||
|
while True:
|
|||
|
with warnings.catch_warnings():
|
|||
|
try:
|
|||
|
x = next(it)
|
|||
|
except StopIteration:
|
|||
|
break
|
|||
|
yield x
|
|||
|
|
|||
|
OTOH, if this PEP is accepted then the original code will become
|
|||
|
correct as-is. Or if this isn't convincing, then here's another
|
|||
|
example of broken code; fixing it requires even greater gyrations, and
|
|||
|
these are left as an exercise for the reader::
|
|||
|
|
|||
|
def f2():
|
|||
|
with warnings.catch_warnings(record=True) as w:
|
|||
|
for x in g():
|
|||
|
yield x
|
|||
|
assert len(w) == 1
|
|||
|
assert "xyzzy" in w[0].message
|
|||
|
|
|||
|
And notice that this last example isn't artificial at all -- if you
|
|||
|
squint, it turns out to be exactly how you write a test that an
|
|||
|
asyncio-using coroutine ``g`` correctly raises a warning. Similar
|
|||
|
issues arise for pretty much any use of ``warnings.catch_warnings``,
|
|||
|
``decimal.localcontext``, or ``numpy.errstate`` in asyncio-using code.
|
|||
|
So there's clearly a real problem to solve here, and the growing
|
|||
|
prominence of async code makes it increasingly urgent.
|
|||
|
|
|||
|
|
|||
|
Alternative approaches
|
|||
|
----------------------
|
|||
|
|
|||
|
The main alternative that has been proposed is to create some kind of
|
|||
|
"task-local storage", analogous to "thread-local storage"
|
|||
|
[#yury-task-local-proposal]_. In essence, the idea would be that the
|
|||
|
event loop would take care to allocate a new "task namespace" for each
|
|||
|
task it schedules, and provide an API to at any given time fetch the
|
|||
|
namespace corresponding to the currently executing task. While there
|
|||
|
are many details to be worked out [#task-local-challenges]_, the basic
|
|||
|
idea seems doable, and it is an especially natural way to handle the
|
|||
|
kind of global context that arises at the top-level of async
|
|||
|
application frameworks (e.g., setting up context objects in a web
|
|||
|
framework). But it also has a number of flaws:
|
|||
|
|
|||
|
* It only solves the problem of managing global state for coroutines
|
|||
|
that ``yield`` back to an asynchronous event loop. But there
|
|||
|
actually isn't anything about this problem that's specific to
|
|||
|
asyncio -- as shown in the examples above, simple generators run
|
|||
|
into exactly the same issue.
|
|||
|
|
|||
|
* It creates an unnecessary coupling between event loops and code that
|
|||
|
needs to manage global state. Obviously an async web framework needs
|
|||
|
to interact with some event loop API anyway, so it's not a big deal
|
|||
|
in that case. But it's weird that ``warnings`` or ``decimal`` or
|
|||
|
NumPy should have to call into an async library's API to access
|
|||
|
their internal state when they themselves involve no async code.
|
|||
|
Worse, since there are multiple event loop APIs in common use, it
|
|||
|
isn't clear how to choose which to integrate with. (This could be
|
|||
|
somewhat mitigated by CPython providing a standard API for creating
|
|||
|
and switching "task-local domains" that asyncio, Twisted, tornado,
|
|||
|
etc. could then work with.)
|
|||
|
|
|||
|
* It's not at all clear that this can be made acceptably fast. NumPy
|
|||
|
has to check the floating point error settings on every single
|
|||
|
arithmetic operation. Checking a piece of data in thread-local
|
|||
|
storage is absurdly quick, because modern platforms have put massive
|
|||
|
resources into optimizing this case (e.g. dedicating a CPU register
|
|||
|
for this purpose); calling a method on an event loop to fetch a
|
|||
|
handle to a namespace and then doing lookup in that namespace is
|
|||
|
much slower.
|
|||
|
|
|||
|
More importantly, this extra cost would be paid on *every* access to
|
|||
|
the global data, even for programs which are not otherwise using an
|
|||
|
event loop at all. This PEP's proposal, by contrast, only affects
|
|||
|
code that actually mixes ``with`` blocks and ``yield`` statements,
|
|||
|
meaning that the users who experience the costs are the same users
|
|||
|
who also reap the benefits.
|
|||
|
|
|||
|
On the other hand, such tight integration between task context and the
|
|||
|
event loop does potentially allow other features that are beyond the
|
|||
|
scope of the current proposal. For example, an event loop could note
|
|||
|
which task namespace was in effect when a task called ``call_soon``,
|
|||
|
and arrange that the callback when run would have access to the same
|
|||
|
task namespace. Whether this is useful, or even well-defined in the
|
|||
|
case of cross-thread calls (what does it mean to have task-local
|
|||
|
storage accessed from two threads simultaneously?), is left as a
|
|||
|
puzzle for event loop implementors to ponder -- nothing in this
|
|||
|
proposal rules out such enhancements as well. It does seem though
|
|||
|
that such features would be useful primarily for state that already
|
|||
|
has a tight integration with the event loop -- while we might want a
|
|||
|
request id to be preserved across ``call_soon``, most people would not
|
|||
|
expect::
|
|||
|
|
|||
|
with warnings.catch_warnings():
|
|||
|
loop.call_soon(f)
|
|||
|
|
|||
|
to result in ``f`` being run with warnings disabled, which would be
|
|||
|
the result if ``call_soon`` preserved global context in general.
|
|||
|
|
|||
|
|
|||
|
Backwards compatibility
|
|||
|
=======================
|
|||
|
|
|||
|
Because ``__suspend__`` and ``__resume__`` are optional and default to
|
|||
|
no-ops, all existing context managers continue to work exactly as
|
|||
|
before.
|
|||
|
|
|||
|
Speed-wise, this proposal adds additional overhead when entering a
|
|||
|
``with`` block (where we must now check for the additional methods;
|
|||
|
failed attribute lookup in CPython is rather slow, since it involves
|
|||
|
allocating an ``AttributeError``), and additional overhead at
|
|||
|
suspension points. Since the position of ``with`` blocks and
|
|||
|
suspension points is known statically, the compiler can
|
|||
|
straightforwardly optimize away this overhead in all cases except
|
|||
|
where one actually has a ``yield`` inside a ``with``.
|
|||
|
|
|||
|
|
|||
|
Interaction with PEP 492
|
|||
|
========================
|
|||
|
|
|||
|
PEP 492 added new asynchronous context managers, which are like
|
|||
|
regular context managers but instead of having regular methods
|
|||
|
``__enter__`` and ``__exit__`` they have coroutine methods
|
|||
|
``__aenter__`` and ``__aexit__``.
|
|||
|
|
|||
|
There are a few options for how to handle these:
|
|||
|
|
|||
|
1) Add ``__asuspend__`` and ``__aresume__`` coroutine methods.
|
|||
|
|
|||
|
One potential difficulty here is that this would add a complication
|
|||
|
to an already complicated part of the bytecode
|
|||
|
interpreter. Consider code like::
|
|||
|
|
|||
|
async def f():
|
|||
|
async with MGR:
|
|||
|
await g()
|
|||
|
|
|||
|
@types.coroutine
|
|||
|
def g():
|
|||
|
yield 1
|
|||
|
|
|||
|
In 3.5, ``f`` gets desugared to something like::
|
|||
|
|
|||
|
@types.coroutine
|
|||
|
def f():
|
|||
|
yield from MGR.__aenter__()
|
|||
|
try:
|
|||
|
yield from g()
|
|||
|
finally:
|
|||
|
yield from MGR.__aexit__()
|
|||
|
|
|||
|
With the addition of ``__asuspend__`` / ``__aresume__``, the
|
|||
|
``yield from`` would have to replaced by something like::
|
|||
|
|
|||
|
for SUBVALUE in g():
|
|||
|
yield from MGR.__asuspend__()
|
|||
|
yield SUBVALUE
|
|||
|
yield from MGR.__aresume__()
|
|||
|
|
|||
|
Notice that we've had to introduce a new temporary ``SUBVALUE`` to
|
|||
|
hold the value yielded from ``g()`` while we yield from
|
|||
|
``MGR.__asuspend__()``. Where does this temporary go? Currently
|
|||
|
``yield from`` is a single bytecode that doesn't modify the stack
|
|||
|
while looping. Also, the above code isn't even complete, because it
|
|||
|
skips over the issue of how to direct ``send``/``throw`` calls to
|
|||
|
the right place at the right time...
|
|||
|
|
|||
|
2) Add plain ``__suspend__`` and ``__resume__`` methods.
|
|||
|
|
|||
|
3) Leave async context managers alone for now until we have more
|
|||
|
experience with them.
|
|||
|
|
|||
|
It isn't entirely clear what use cases even exist in which an async
|
|||
|
context manager would need to set coroutine-local-state (= like
|
|||
|
thread-local-state, but for a coroutine stack instead of an OS
|
|||
|
thread), and couldn't do so via coordination with the coroutine
|
|||
|
runner. So this draft tentatively goes with option (3) and punts on
|
|||
|
this question until later.
|
|||
|
|
|||
|
|
|||
|
References
|
|||
|
==========
|
|||
|
|
|||
|
.. [#yury-task-local-proposal] https://groups.google.com/forum/#!topic/python-tulip/zix5HQxtElg
|
|||
|
https://github.com/python/asyncio/issues/165
|
|||
|
|
|||
|
.. [#task-local-challenges] For example, we would have to decide
|
|||
|
whether there is a single task-local namespace shared by all users
|
|||
|
(in which case we need a way for multiple third-party libraries to
|
|||
|
adjudicate access to this namespace), or else if there are multiple
|
|||
|
task-local namespaces, then we need some mechanism for each library
|
|||
|
to arrange for their task-local namespaces to be created and
|
|||
|
destroyed at appropriate moments. The preliminary patch linked
|
|||
|
from the github issue above doesn't seem to provide any mechanism
|
|||
|
for such lifecycle management.
|
|||
|
|
|||
|
|
|||
|
Copyright
|
|||
|
=========
|
|||
|
|
|||
|
This document has been placed in the public domain.
|
|||
|
|
|||
|
|
|||
|
|
|||
|
..
|
|||
|
Local Variables:
|
|||
|
mode: indented-text
|
|||
|
indent-tabs-mode: nil
|
|||
|
sentence-end-double-space: t
|
|||
|
fill-column: 70
|
|||
|
coding: utf-8
|
|||
|
End:
|