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 的调用链(或调用树),在本提案中,它指的是使用普通函数调用、await 表达式或 yield 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

但是,如果其净内容包含反赋值,则可能无法重新应用“增量”(另请参阅实现和开放问题)。

获取上下文状态的快照

函数 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 3.6 中的行为(此处 _old_send 指的是 Python 3.6 中的行为)的 Python 等价更改。

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

最后修改: 2025-02-01 08:59:27 GMT