Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

PEP 555 – 上下文局部变量 (contextvars)

作者:
Koos Zevenhoven
状态:
已撤回
类型:
标准轨迹
创建:
2017年9月6日
Python 版本:
3.7
历史记录:
2017年9月6日

目录

摘要

有时,在特殊情况下,希望代码能够将信息传递到函数调用链中的被调用方,而无需将信息作为参数显式地传递到调用链中的每个函数。本提案描述了一种构造,允许代码显式地切换进出某个上下文,在该上下文中,某个上下文变量被分配了一个给定的值。这是对传统单线程(或线程不安全)代码中全局变量以及传统并发不安全代码(单线程或多线程)中线程局部存储的一些用法的现代替代方案。特别是,所提出的机制也可以与更现代的并发执行机制(如异步执行的协程)一起使用,而不会使并发执行的调用链相互干扰其上下文。

“调用链”可以由普通函数、等待的协程或生成器组成。上下文变量作用域的语义在所有情况下都是等效的,允许代码自由地重构为子程序(此处指函数、子生成器或子协程),而不会影响上下文变量的语义。关于实现,本提案旨在简化并最大程度地减少对 CPython 解释器和其他 Python 解释器的更改。

基本原理

考虑一个现代 Python 的调用链(或调用树),在本提案中,它指的是任何使用任何可能的普通函数调用的组合或使用awaityield from的表达式的子程序的链式(嵌套)执行。在某些情况下,将必要的信息作为参数传递到调用链中会大大增加所需的函数签名复杂度,或者在实践中甚至可能无法实现。在这些情况下,人们可能会寻找另一个存储此信息的地方。让我们看一些历史示例。

最简单的选项是将值分配给全局变量或类似的变量,其中调用链中的代码可以访问它。但是,这立即使代码变得线程不安全,因为在多线程中,所有线程都分配给同一个全局变量,并且另一个线程可以在调用链中的任何点干扰。迟早,有人可能会找到一个原因来在并行线程中运行相同的代码。

一个稍微不那么简单的选项是将信息存储为线程局部存储中的每个线程信息,其中每个线程都有自己的变量“副本”,其他线程无法干扰它。虽然不是理想的,但这在许多情况下一直是最佳解决方案。但是,由于生成器和协程,调用链的执行可以被挂起和恢复,允许其他上下文中的代码并发运行。因此,使用线程局部存储是并发不安全的,因为其他上下文中的其他调用链可能会干扰线程局部变量。

请注意,在上述两种历史方法中,存储的信息具有最广的可用作用域,而不会导致问题。对于沿着相同路径的第三种解决方案,首先会为异步执行和并发定义“线程”的等效项。这可以被视为最大数量的代码和嵌套调用,保证以无歧义的执行顺序顺序执行。这可能被称为并发局部或任务局部存储。在这种“任务”的含义下,一个任务中代码的执行顺序没有歧义。(这个任务的概念接近于asyncio中的Task,但并不完全相同。)在这样的并发局部中,可以将信息传递到调用链中的被调用方,而不会让其他代码路径在后台干扰该值。

上述方法的共同点是,它们确实使用了具有广泛但恰好足够窄的作用域的变量。线程局部也可以称为线程范围的全局变量——在单线程代码中,它们确实是真正的全局变量。任务局部可以称为任务范围的全局变量,因为任务可能非常大。

这里的问题是,全局变量、线程局部变量或任务局部变量都不是真正为了传递执行上下文信息到调用链而使用的。变量的作用域不应该是尽可能广,而应该由程序员(通常是库的程序员)控制,以获得所需的作用域——而不是更广。换句话说,任务局部变量(以及全局变量和线程局部变量)与本提案试图实现的上下文绑定信息传递类型无关,即使任务局部变量可以用来模拟所需的语义。因此,在下面,本提案描述了上下文局部变量(或上下文变量、contextvars)的语义和实现概述。事实上,作为本 PEP 的一个副作用,异步框架可以使用所提出的特性来实现任务局部变量。

提案

由于提出的语义不是对 Python 中已有的任何内容的直接扩展,因此本提案首先以相当高的级别描述语义和 API。特别是,Python with 语句在描述中被大量使用,因为它们与提出的语义非常匹配。但是,底层的__enter____exit__方法对应于低级速度优化的 (C) API 中的函数。为了使本文档更清晰,在语义定义中没有明确命名低级函数。在描述语义和高级 API 之后,将描述实现,并转向更低的级别。

语义和高级 API

核心概念

上下文局部变量由contextvars.Var的一个实例表示,例如cvar。任何可以访问cvar对象的代码都可以根据当前上下文请求其值。在高级 API 中,此值由cvar.value属性给出。

cvar = contextvars.Var(default="the default value",
                       description="example context variable")

assert cvar.value == "the default value"  # default still applies

# In code examples, all ``assert`` statements should
# succeed according to the proposed semantics.

对于此上下文,尚未应用对cvar的任何赋值,因此cvar.value给出默认值。对 contextvars 的赋值是以高度作用域感知的方式完成的。

with cvar.assign(new_value):
    assert cvar.value is new_value
    # Any code here, or down the call chain from here, sees:
    #     cvar.value is new_value
    # unless another value has been assigned in a
    # nested context
    assert cvar.value is new_value
# the assignment of ``cvar`` to ``new_value`` is no longer visible
assert cvar.value == "the default value"

这里,cvar.assign(value)返回另一个对象,即contextvars.Assignment(cvar, new_value)。这里的重要部分是,应用上下文变量赋值(Assignment.__enter__)与反分配(Assignment.__exit__)配对。这些操作设置了分配值的作用域边界。

对同一个上下文变量的赋值可以嵌套,以便在一个更窄的上下文中覆盖外部赋值。

assert cvar.value == "the default value"
with cvar.assign("outer"):
    assert cvar.value == "outer"
    with cvar.assign("inner"):
        assert cvar.value == "inner"
    assert cvar.value == "outer"
assert cvar.value == "the default value"

多个变量也可以以嵌套的方式进行赋值,而不会相互影响。

cvar1 = contextvars.Var()
cvar2 = contextvars.Var()

assert cvar1.value is None # default is None by default
assert cvar2.value is None

with cvar1.assign(value1):
    assert cvar1.value is value1
    assert cvar2.value is None
    with cvar2.assign(value2):
        assert cvar1.value is value1
        assert cvar2.value is value2
    assert cvar1.value is value1
    assert cvar2.value is None
assert cvar1.value is None
assert cvar2.value is None

或者使用更方便的 Python 语法。

with cvar1.assign(value1), cvar2.assign(value2):
    assert cvar1.value is value1
    assert cvar2.value is value2

在另一个上下文中,在另一个线程中或以其他方式并发执行的任务或代码路径中,上下文变量可以具有完全不同的状态。因此,程序员只需要关心手头的上下文。

重构为子程序

使用 contextvars 的代码可以重构为子程序,而不会影响语义。例如。

assi = cvar.assign(new_value)
def apply():
    assi.__enter__()
assert cvar.value == "the default value"
apply()
assert cvar.value is new_value
assi.__exit__()
assert cvar.value == "the default value"

或者类似地在异步上下文中使用await表达式。子程序现在可以是协程。

assi = cvar.assign(new_value)
async def apply():
    assi.__enter__()
assert cvar.value == "the default value"
await apply()
assert cvar.value is new_value
assi.__exit__()
assert cvar.value == "the default value"

或者当子程序是生成器时。

def apply():
    yield
    assi.__enter__()

它使用yield from apply()next.send调用。这将在后面的部分进一步讨论。

生成器和基于生成器的协程的语义

生成器、协程和异步生成器与普通函数的作用方式非常相似,充当子程序。但是,它们还有可能通过yield表达式被挂起。在生成器内部进入的赋值上下文通常在 yield 之间保留。

def genfunc():
    with cvar.assign(new_value):
        assert cvar.value is new_value
        yield
        assert cvar.value is new_value
g = genfunc()
next(g)
assert cvar.value == "the default value"
with cvar.assign(another_value):
    next(g)

但是,生成器可见的外部上下文可能会在 yield 之间更改状态。

def genfunc():
    assert cvar.value is value2
    yield
    assert cvar.value is value1
    yield
    with cvar.assign(value3):
        assert cvar.value is value3

with cvar.assign(value1):
    g = genfunc()
    with cvar.assign(value2):
        next(g)
    next(g)
    next(g)
    assert cvar.value is value1

类似的语义适用于由async def ... yield ...定义的异步生成器)。

默认情况下,在生成器内部分配的值不会通过 yield 泄漏到驱动生成器的代码中。但是,在生成器内部进入并保持打开状态的赋值上下文在生成器使用StopIteration或其他异常完成之后在生成器外部可见。

assi = cvar.assign(new_value)
def genfunc():
    yield
    assi.__enter__():
    yield

g = genfunc()
assert cvar.value == "the default value"
next(g)
assert cvar.value == "the default value"
next(g)  # assi.__enter__() is called here
assert cvar.value == "the default value"
next(g)
assert cvar.value is new_value
assi.__exit__()

框架作者的特殊功能

框架(如asyncio或第三方库)可以使用contextvars中的附加功能来实现所需的语义,这些语义不是由 Python 解释器确定的。本节中描述的一些语义之后也将用于描述内部实现。

泄漏 yield

使用contextvars.leaking_yields装饰器,可以选择通过yield表达式将上下文泄漏到驱动生成器的外部上下文中。

@contextvars.leaking_yields
def genfunc():
    assert cvar.value == "outer"
    with cvar.assign("inner"):
        yield
        assert cvar.value == "inner"
    assert cvar.value == "outer"

g = genfunc():
with cvar.assign("outer"):
    assert cvar.value == "outer"
    next(g)
    assert cvar.value == "inner"
    next(g)
    assert cvar.value == "outer"

捕获 contextvar 赋值

使用contextvars.capture(),可以捕获代码块进入的赋值上下文。然后可以恢复代码块应用的更改,并在随后重新应用,即使在另一个上下文中也是如此。

assert cvar1.value is None # default
assert cvar2.value is None # default
assi1 = cvar1.assign(value1)
assi2 = cvar1.assign(value2)
with contextvars.capture() as delta:
    assi1.__enter__()
    with cvar2.assign("not captured"):
        assert cvar2.value is "not captured"
    assi2.__enter__()
assert cvar1.value is value2
delta.revert()
assert cvar1.value is None
assert cvar2.value is None
...
with cvar1.assign(1), cvar2.assign(2):
    delta.reapply()
    assert cvar1.value is value2
    assert cvar2.value == 2

然而,如果“delta”的净内容包含取消分配,则可能无法重新应用它(另请参阅实现和未解决问题)。

获取上下文状态的快照

函数contextvars.get_local_state()返回一个对象,表示在调用该函数的上下文中对所有上下文局部变量应用的赋值。这可以看作等同于使用contextvars.capture()从执行开始捕获所有上下文更改。返回的对象支持方法.revert()reapply(),如上所述。

在干净状态下运行代码

虽然可以使用上述基本方法撤消所有应用的上下文更改,但提供了一种更方便的方法来在干净的上下文中运行代码块。

with context_vars.clean_context():
    # here, all context vars start off with their default values
# here, the state is back to what it was before the with block.

实现

本节描述了如何以不同程度的详细程度实现所描述的语义。目前,描述了一种旨在简化但功能足够的实现。稍后将添加更多详细信息。

或者,一个稍微复杂一点的实现提供了少量额外的功能,同时增加了一些性能开销,并在实现中需要更多代码。

核心概念的数据结构和实现

Python 解释器的每个线程都保留自己的contextvars.Assignment对象堆栈,每个对象都像在链表中一样指向先前的(外部)赋值。本地状态(也由contextvars.get_local_state()返回)然后由对堆栈顶部的引用和对堆栈底部的指针/弱引用组成。这允许高效的堆栈操作。由contextvars.capture()产生的对象类似,但仅引用堆栈的一部分,底部引用指向捕获块开始时堆栈的顶部。

现在,堆栈根据赋值__enter____exit__方法演变。例如

cvar1 = contextvars.Var()
cvar2 = contextvars.Var()
# stack: []
assert cvar1.value is None
assert cvar2.value is None

with cvar1.assign("outer"):
    # stack: [Assignment(cvar1, "outer")]
    assert cvar1.value == "outer"

    with cvar1.assign("inner"):
        # stack: [Assignment(cvar1, "outer"),
        #         Assignment(cvar1, "inner")]
        assert cvar1.value == "inner"

        with cvar2.assign("hello"):
            # stack: [Assignment(cvar1, "outer"),
            #         Assignment(cvar1, "inner"),
            #         Assignment(cvar2, "hello")]
            assert cvar2.value == "hello"

        # stack: [Assignment(cvar1, "outer"),
        #         Assignment(cvar1, "inner")]
        assert cvar1.value == "inner"
        assert cvar2.value is None

    # stack: [Assignment(cvar1, "outer")]
    assert cvar1.value == "outer"

# stack: []
assert cvar1.value is None
assert cvar2.value is None

从上下文中获取值使用cvar1.value可以实现为在堆栈上查找cvar1赋值的最顶层出现,并在那里返回值,或者如果在堆栈上找不到赋值则返回默认值。但是,这可以在大多数情况下优化为 O(1) 操作。尽管如此,即使搜索堆栈也可能相当快,因为这些堆栈不打算增长得很大。

以上描述已经足以实现核心概念。可挂起帧需要一些额外的注意,如下所述。

生成器和协程语义的实现

在生成器、协程和异步生成器中,赋值和取消赋值的处理方式与任何其他地方完全相同。但是,内置生成器方法send__next__throwclose需要一些更改。以下是生成器中send所需更改的 Python 等效项(此处_old_send指的是 Python 3.6 中的行为)

def send(self, value):
    if self.gi_contextvars is LEAK:
        # If decorated with contextvars.leaking_yields.
        # Nothing needs to be done to leak context through yields :)
        return self._old_send(value)
    try:
        with contextvars.capture() as delta:
            if self.gi_contextvars:
                # non-zero captured content from previous iteration
                self.gi_contextvars.reapply()
            ret = self._old_send(value)
    except Exception:
        raise  # back to the calling frame (e.g. StopIteration)
    else:
        # suspending, revert context changes but save them for later
        delta.revert()
        self.gi_contextvars = delta
    return ret

对其他方法的相应修改本质上是相同的。协程和异步生成器也适用。

对于不使用contextvars的代码,这些添加是 O(1),并且本质上减少到几个指针比较。对于确实使用contextvars的代码,在大多数情况下这些添加仍然是 O(1)。

关于实现的更多信息

其余功能,包括contextvars.leaking_yieldscontextvars.capture()contextvars.get_local_state()contextvars.clean_context()实际上非常容易实现,但它们的实现将在该提案的后续版本中进一步讨论。已赋值值的缓存稍微复杂一些,将在稍后讨论,但似乎大多数情况都应该达到 O(1) 的复杂度。

向后兼容性

没有直接的向后兼容性问题,因为建议了一个全新的功能。

但是,线程本地存储的各种传统用法可能需要平滑过渡到contextvars,以便它们可以并发安全。有几种方法可以做到这一点,包括在异步框架的一点帮助下模拟任务本地存储。无法提供完全通用的实现,因为所需的语义可能取决于框架的设计。

处理过渡的另一种方法是,代码首先查找使用contextvars创建的上下文。如果由于未设置新式上下文或代码在旧版 Python 版本上运行而导致失败,则使用回退到线程本地存储。

未解决的问题

无序反分配

在本提案中,所有变量取消分配都与之前的分配相比以相反的顺序进行。这有两个有用的特性:它鼓励使用with语句来定义赋值范围,并且有倾向于尽早捕获错误(忘记.__exit__()调用通常会导致有意义的错误。将此作为一项要求在实现简单性和性能方面也有好处。尽管如此,允许无序的上下文退出并非完全不可能,并且确实存在合理的实现策略。

被拒绝的想法

与子程序作用域关联的动态作用域

值可见性的范围不应由代码重构为子例程的方式决定。有必要对赋值范围进行按变量控制。

致谢

待添加。

参考文献

待添加。


来源:https://github.com/python/peps/blob/main/peps/pep-0555.rst

上次修改时间:2023-09-09 17:39:29 GMT