Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 增强提案

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,而是保存一个 ChainMapContextContextVar.getContextVar.setChainMap 支持。生成器和异步生成器各自拥有一个相关的 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_contexttstate.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__sendthrowclose 进入生成器,或者通过调用其 __anext__asendathrowaclose 协程上的这些方法之一进入异步生成器,则检查其 .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 的主要区别在于它将我们所说的“上下文”和“上下文堆栈”具体化为两种不同的具体类型(分别是 LocalContextExecutionContext)。这导致了关于差异以及在哪些地方应该使用哪个对象的许多困惑。此提案通过仅具体化 Context 来简化事情,它“只是一个字典”,并且使“上下文堆栈”成为解释器运行时状态的一个未命名的功能——尽管仍然可以使用 get_context_stack 来检查它,用于调试和其他目的。

实现说明

Context 将继续在幕后使用基于 HAMT 的映射结构,而不是 dict,因为我们预计对 copy_context 的调用比对 ContextVar.set 的调用要常见得多。在几乎所有情况下,copy_context 都会发现堆栈中只有一个 Context(因为生成器很少生成新任务),并且可以直接重复使用它;在其他情况下,HAMT 的合并成本很低,并且可以延迟地完成。

与其使用实际的 ChainMap 对象,我们将使用一些合适的结构来表示上下文堆栈——最合适的选项可能是裸 list,其中堆栈的“顶部”是列表的末尾,这样我们就可以使用 push/pop,或者是一个侵入式链表 (PyThreadStateContextContext → …),其中堆栈的“顶部”在列表的开头,以允许高效的 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