PEP 568 – 上下文变量的生成器敏感度
- 作者:
- Nathaniel J. Smith <njs at pobox.com>
- 状态:
- 推迟
- 类型:
- 标准跟踪
- 创建日期:
- 2018 年 1 月 4 日
- Python 版本:
- 3.8
- 发布历史:
摘要
上下文变量提供了一种跟踪动态、上下文局部状态的通用机制,类似于线程局部存储,但被推广以适应其他类型的线程状上下文,例如 asyncio 任务。PEP 550 提出了一种也对生成器上下文敏感的上下文局部状态机制,但这相当复杂,因此 BDFL 要求简化它。结果是PEP 567,它计划包含在 3.7 中。本 PEP 随后扩展了PEP 567的机制,以添加生成器上下文敏感度。
本 PEP 以“延迟”状态启动,因为在 3.7 功能冻结之前没有足够的时间进行适当的考虑。现在唯一的目标是了解在 3.8 中添加生成器上下文敏感性需要什么,这样我们就可以避免在 3.7 中意外发布一些会将其排除在外的内容。(有意排除可以等到 3.8 ;-)。)
基本原理
[目前,本 PEP 的目的只是为了理解这将如何工作,关于它是否是一个好主意的讨论将推迟到 3.7 功能冻结之后。因此,基本原理待定。]
高级摘要
线程状态现在不再是持有单个 Context,而是持有 ChainMap 的 Context。 ContextVar.get 和 ContextVar.set 由 ChainMap 支持。生成器和异步生成器各自有一个关联的 Context,它们在运行时将其推送到 ChainMap 上,以将其上下文局部更改与调用者隔离开来,尽管在像 @contextlib.contextmanager 这样希望将上下文更改从生成器“泄漏”到其调用者的情况中,这可以被覆盖。
规范
PEP 567 审查
我们首先回顾一下 PEP 567 的工作原理,然后在下一节中描述差异。
在PEP 567中,Context是从ContextVar对象到任意值的Mapping。在我们的伪代码中,我们将假装它使用dict作为后端存储。(实际实现使用HAMT,它在语义上等同于dict,但具有不同的性能权衡。)
class Context(collections.abc.Mapping):
def __init__(self):
self._data = {}
self._in_use = False
def __getitem__(self, key):
return self._data[key]
def __iter__(self):
return iter(self._data)
def __len__(self):
return len(self._data)
在任何给定时刻,线程状态都持有一个当前的 Context(在线程状态创建时初始化为空 Context);我们可以使用 Context.run 来临时切换当前的 Context
# Context.run
def run(self, fn, *args, **kwargs):
if self._in_use:
raise RuntimeError("Context already in use")
tstate = get_thread_state()
old_context = tstate.current_context
tstate.current_context = self
self._in_use = True
try:
return fn(*args, **kwargs)
finally:
state.current_context = old_context
self._in_use = False
我们可以通过调用 copy_context 来获取当前 Context 的浅拷贝;这通常用于生成新任务时,以便子任务可以从其父任务继承上下文
def copy_context():
tstate = get_thread_state()
new_context = Context()
new_context._data = dict(tstate.current_context)
return new_context
实际上,终端用户通常处理的是 ContextVar 对象,它们也提供了修改 Context 的唯一方法。它们与一个实用程序类 Token 协同工作,该类可用于将 ContextVar 恢复到其先前的值
class Token:
MISSING = sentinel_value()
# Note: constructor is private
def __init__(self, context, var, old_value):
self._context = context
self.var = var
self.old_value = old_value
# XX: PEP 567 currently makes this a method on ContextVar, but
# I'm going to propose it switch to this API because it's simpler.
def reset(self):
# XX: should we allow token reuse?
# XX: should we allow tokens to be used if the saved
# context is no longer active?
if self.old_value is self.MISSING:
del self._context._data[self.context_var]
else:
self._context._data[self.context_var] = self.old_value
# XX: the handling of defaults here uses the simplified proposal from
# https://mail.python.org/pipermail/python-dev/2018-January/151596.html
# This can be updated to whatever we settle on, it was just less
# typing this way :-)
class ContextVar:
def __init__(self, name, *, default=None):
self.name = name
self.default = default
def get(self):
context = get_thread_state().current_context
return context.get(self, self.default)
def set(self, new_value):
context = get_thread_state().current_context
token = Token(context, self, context.get(self, Token.MISSING))
context._data[self] = new_value
return token
PEP 567 到本 PEP 的变化
一般来说,Context 保持不变。但是,现在线程状态不再持有单个 Context 对象,而是存储一个 Context 栈。这个栈的行为就像一个 collections.ChainMap,所以我们将在伪代码中使用它。Context.run 变为
# Context.run
def run(self, fn, *args, **kwargs):
if self._in_use:
raise RuntimeError("Context already in use")
tstate = get_thread_state()
old_context_stack = tstate.current_context_stack
tstate.current_context_stack = ChainMap([self]) # changed
self._in_use = True
try:
return fn(*args, **kwargs)
finally:
state.current_context_stack = old_context_stack
self._in_use = False
除了一些更新的变量名称(例如,tstate.current_context → tstate.current_context_stack)之外,这里唯一的更改是标记行,该行现在将上下文包装在 ChainMap 中,然后再将其存储在线程状态中。
我们还添加了一个 Context.push 方法,它与 Context.run 几乎完全相同,不同之处在于它暂时将 Context 推送到现有栈上,而不是暂时替换整个栈
# Context.push
def push(self, fn, *args, **kwargs):
if self._in_use:
raise RuntimeError("Context already in use")
tstate = get_thread_state()
tstate.current_context_stack.maps.insert(0, self) # different from run
self._in_use = True
try:
return fn(*args, **kwargs)
finally:
tstate.current_context_stack.maps.pop(0) # different from run
self._in_use = False
在大多数情况下,我们不期望直接使用 push;相反,它将由生成器隐式使用。具体来说,每个生成器对象和异步生成器对象都会获得一个新属性 .context。当创建(异步)生成器对象时,此属性被初始化为空 Context(self.context = Context())。这是一个可变属性;它可以由用户代码更改。但是,如果尝试将其设置为不是 Context 对象或 None 的任何值,则会引发错误。
无论何时我们通过 __next__、send、throw 或 close 进入生成器,或者通过调用其 __anext__、asend、athrow 或 aclose 协程的这些方法之一进入异步生成器,则会检查其 .context 属性,如果不是 None,则会自动推送
# GeneratorType.__next__
def __next__(self):
if self.context is not None:
return self.context.push(self.__real_next__)
else:
return self.__real_next__()
虽然我们不期望人们经常使用 Context.push,但将其作为公共 API 保留了生成器始终可以重写为具有等效语义的显式迭代器类的原则。
此外,我们修改了 contextlib.(async)contextmanager,使其始终将其(异步)生成器对象的 .context 属性设置为 None
# contextlib._GeneratorContextManagerBase.__init__
def __init__(self, func, args, kwds):
self.gen = func(*args, **kwds)
self.gen.context = None # added
...
这确保了这样的代码继续按预期工作
@contextmanager
def decimal_precision(prec):
with decimal.localcontext() as ctx:
ctx.prec = prec
yield
with decimal_precision(2):
...
这里的总体思路是,默认情况下,每个生成器对象都有自己的本地上下文,但如果用户希望明确获得其他行为,他们可以这样做。
否则,事情大部分与之前相同,只是我们遍历并切换所有内容以使用线程状态 ChainMap 而不是线程状态 Context。详细内容如下
copy_context 函数现在返回“有效”上下文的扁平副本。(作为一种优化,实现可能会选择惰性执行此扁平化,但如果这样做,将对用户不可见。)与我们上面先前的实现相比,这里唯一的更改是 tstate.current_context 已被 tstate.current_context_stack 替换
def copy_context() -> Context:
tstate = get_thread_state()
new_context = Context()
new_context._data = dict(tstate.current_context_stack)
return new_context
Token 未更改,对 ContextVar.get 的更改微不足道
# ContextVar.get
def get(self):
context_stack = get_thread_state().current_context_stack
return context_stack.get(self, self.default)
ContextVar.set 有点意思:它不像其他任何东西那样通过 ChainMap 机制,而是总是修改堆栈中最顶层的 Context,并且——关键是!——设置返回的 Token 以在稍后恢复其状态。这使我们能够避免意外地在堆栈的不同级别之间“提升”值,就像我们执行 old = var.get(); ...; var.set(old) 时发生的那样
# ContextVar.set
def set(self, new_value):
top_context = get_thread_state().current_context_stack.maps[0]
token = Token(top_context, self, top_context.get(self, Token.MISSING))
top_context._data[self] = new_value
return token
最后,为了允许对完整的上下文栈进行内省,我们提供了一个新函数 contextvars.get_context_stack
def get_context_stack() -> List[Context]:
return list(get_thread_state().current_context_stack.maps)
就这些。
与 PEP 550 的比较
与 PEP 550 的主要区别在于,它将我们所谓的“上下文”和“上下文堆栈”具体化为两种不同的具体类型(分别是 LocalContext 和 ExecutionContext)。这导致了关于它们之间的区别以及应该在哪些地方使用哪个对象的许多困惑。本提案通过只具体化 Context(它“只是一个字典”)来简化问题,并使“上下文堆栈”成为解释器运行时状态的一个无名特性——尽管它仍然可以使用 get_context_stack 进行自省,用于调试和其他目的。
实现注意事项
Context 将继续在底层使用基于 HAMT 的映射结构而不是 dict,因为我们预计调用 copy_context 比 ContextVar.set 更常见。在几乎所有情况下,copy_context 将发现栈中只有一个 Context(因为生成器很少产生新任务),并且可以简单地直接重用它;在其他情况下,HAMT 的合并成本很低,并且可以惰性完成。
我们将使用适当的结构来表示上下文堆栈,而不是使用实际的 ChainMap 对象——最合适的选项可能是一个裸 list,其堆栈的“顶部”是列表的末尾,这样我们就可以使用 push/pop,或者是一个侵入式链表(PyThreadState → Context → Context → …),其堆栈的“顶部”在列表的开头,以允许高效的 push/pop。
PEP 567 中的一个关键优化是 ContextVar 内部的值缓存。从单个上下文切换到上下文栈会使这变得稍微复杂一些,但不会太多。目前,当线程状态的当前 Context 更改时(在线程切换时,以及进入/退出 Context.run 时),我们会使缓存失效。这里最简单的方法是当栈更改时使缓存失效(在线程切换时,当进入/退出 Context.run 时,以及当进入/离开 Context.push 时)。这主要影响是迭代生成器会使缓存失效。这似乎不太可能导致严重问题,但如果确实如此,那么我认为可以通过一个更巧妙的缓存键来避免,该键能够识别推送然后弹出 Context 会使线程状态返回到其以前的状态。(想法:将特定栈配置的缓存键存储在最顶层的 Context 中。)
在这种设计中,未缓存的 get 将是 O(n),其中 n 是上下文堆栈的大小,这似乎是不可避免的。然而,n 通常会非常小——它大致是嵌套生成器的数量,所以通常 n=1,并且 n 大于 5 的情况会非常罕见。最坏情况下,n 受递归限制的约束。此外,我们可以预期,在大多数深度生成器递归的情况下,堆栈中的大多数 Context 将是空的,因此在查找过程中可以非常快速地跳过。对于重复查找,缓存机制将发挥作用。因此,构造一些导致性能问题的极端情况可能是可能的,但普通代码应该基本不受影响。
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0568.rst
最后修改时间:2025-02-01 08:55:40 GMT