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
对象,而是保存了一个它们的堆栈。此堆栈的行为就像 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
最后修改时间:2023-09-09 17:39:29 GMT