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

Python 增强提案

PEP 567 – 上下文变量

作者:
Yury Selivanov <yury at edgedb.com>
状态:
最终
类型:
标准跟踪
创建:
2017-12-12
Python 版本:
3.7
历史记录:
2017-12-12, 2017-12-28, 2018-01-16

目录

摘要

本 PEP 提出一个新的 contextvars 模块和一组新的 CPython C API 来支持上下文变量。这个概念类似于线程本地存储 (TLS),但与 TLS 不同的是,它还允许正确地跟踪每个异步任务的值,例如 asyncio.Task

此提案是 PEP 550 的简化版本。主要区别在于,此 PEP 仅关注解决异步任务的情况,而不是生成器。对任何内置类型或解释器都没有建议的修改。

此提案与 Python 上下文管理器没有严格的关系。尽管它确实提供了一种机制,上下文管理器可以使用它来存储其状态。

API 设计和实现修订

在 **Python 3.7.1** 中,所有上下文变量 C API 的签名都已 **更改** 为使用 PyObject * 指针而不是 PyContext *PyContextVar *PyContextToken *,例如

// in 3.7.0:
PyContext *PyContext_New(void);

// in 3.7.1+:
PyObject *PyContext_New(void);

有关更多详细信息,请参阅 [6]。本 PEP 的 C API 部分已更新以反映此更改。

基本原理

线程本地变量不足以满足在同一操作系统线程中并发执行的异步任务。任何使用 threading.local() 保存和恢复上下文值的上下文管理器,当在 async/await 代码中使用时,其上下文值将意外地泄漏到其他代码。

一些希望为异步代码提供有效的上下文本地存储的示例

  • decimal 上下文和 numpy.errstate 这样的上下文管理器。
  • 请求相关数据,例如 Web 应用程序中的安全令牌和请求数据、gettext 的语言上下文等。
  • 大型代码库中的分析、跟踪和日志记录。

简介

PEP 提出了一种管理上下文变量的新机制。此机制中涉及的关键类是 contextvars.Contextcontextvars.ContextVar。PEP 还提出了一些围绕异步任务使用该机制的策略。

访问上下文变量的提议机制使用 ContextVar 类。希望使用新机制的模块(例如 decimal)应

  • 声明一个保存 ContextVar 的模块全局变量作为键;
  • 通过键变量上的 get() 方法访问当前值;
  • 通过键变量上的 set() 方法修改当前值。

“当前值”的概念需要特别考虑:并发存在和执行的不同异步任务可能对同一键具有不同的值。这个想法从线程本地存储中广为人知,但在这种情况下,值的局部性不一定绑定到线程。相反,存在“当前 Context”的概念,它存储在线程本地存储中。当前上下文的操纵是任务框架(例如 asyncio)的责任。

ContextContextVar 对象与其值的映射。 Context 本身公开了 abc.Mapping 接口(而不是 abc.MutableMapping!),因此不能直接修改。要为 Context 对象中的上下文变量设置新值,用户需要

  • 使用 Context.run() 方法使 Context 对象“当前”;
  • 使用 ContextVar.set() 为上下文变量设置新值。

ContextVar.get() 方法使用 self 作为键在当前 Context 对象中查找变量。

无法获取对当前 Context 对象的直接引用,但可以使用 contextvars.copy_context() 函数获取其浅拷贝。这确保了 Context.run() 的 *调用者* 是其 Context 对象的唯一所有者。

规范

添加了一个新的标准库模块 contextvars,其中包含以下 API

  1. copy_context() -> Context 函数用于获取当前操作系统线程的当前 Context 对象的副本。
  2. ContextVar 类用于声明和访问上下文变量。
  3. Context 类封装了上下文状态。每个操作系统线程都存储对当前 Context 实例的引用。无法直接控制该引用。相反,Context.run(callable, *args, **kwargs) 方法用于在另一个上下文中运行 Python 代码。

contextvars.ContextVar

ContextVar 类具有以下构造函数签名:ContextVar(name, *, default=_NO_DEFAULT)name 参数用于自省和调试目的,并作为只读 ContextVar.name 属性公开。 default 参数是可选的。示例

# Declare a context variable 'var' with the default value 42.
var = ContextVar('var', default=42)

_NO_DEFAULT 是用于检测是否提供了默认值的内部哨兵对象。)

ContextVar.get(default=_NO_DEFAULT) 返回当前 Context 的上下文变量的值

# Get the value of `var`.
var.get()

如果当前上下文中没有变量的值,则 ContextVar.get()

  • 返回 get() 方法的 *default* 参数的值(如果提供);或者
  • 返回上下文变量的默认值(如果提供);或者
  • 引发 LookupError

ContextVar.set(value) -> Token 用于在当前 Context 中为上下文变量设置新值

# Set the variable 'var' to 1 in the current context.
var.set(1)

ContextVar.reset(token) 用于将当前上下文中变量重置为创建 tokenset() 操作之前的其值(或如果未设置则删除变量)

# Assume: var.get(None) is None

# Set 'var' to 1:
token = var.set(1)
try:
    # var.get() == 1
finally:
    var.reset(token)

# After reset: var.get(None) is None,
# i.e. 'var' was removed from the current context.

ContextVar.reset() 方法引发

  • 如果使用另一个变量创建的令牌对象调用它,则引发 ValueError
  • 如果当前 Context 对象与创建令牌对象的上下文对象不匹配,则引发 ValueError
  • 如果令牌对象已被使用一次来重置变量,则引发 RuntimeError

contextvars.Token

contextvars.Token 是一个不透明的对象,应将其用于将 ContextVar 恢复到其先前值,或者如果变量之前未设置,则将其从上下文中删除。它只能通过调用 ContextVar.set() 来创建。

出于调试和自省的目的,它具有

  • 一个指向创建令牌的变量的只读属性 Token.var
  • 一个设置为变量在 set() 调用之前的值的只读属性 Token.old_value,或者如果变量之前未设置,则设置为 Token.MISSING

contextvars.Context

Context 对象是上下文变量到值的映射。

Context() 创建一个空上下文。要获取当前操作系统线程的当前 Context 的副本,请使用 contextvars.copy_context() 方法

ctx = contextvars.copy_context()

要在某个 Context 中运行 Python 代码,请使用 Context.run() 方法

ctx.run(function)

任何对function导致的任何上下文变量的更改都将包含在ctx上下文中。

var = ContextVar('var')
var.set('spam')

def main():
    # 'var' was set to 'spam' before
    # calling 'copy_context()' and 'ctx.run(main)', so:
    # var.get() == ctx[var] == 'spam'

    var.set('ham')

    # Now, after setting 'var' to 'ham':
    # var.get() == ctx[var] == 'ham'

ctx = copy_context()

# Any changes that the 'main' function makes to 'var'
# will be contained in 'ctx'.
ctx.run(main)

# The 'main()' function was run in the 'ctx' context,
# so changes to 'var' are contained in it:
# ctx[var] == 'ham'

# However, outside of 'ctx', 'var' is still set to 'spam':
# var.get() == 'spam'

Context.run()在从多个操作系统线程调用同一个上下文对象时,或在递归调用时,会引发RuntimeError

Context.copy()返回上下文对象的浅拷贝。

Context对象实现了collections.abc.Mapping ABC。这可以用于检查上下文。

ctx = contextvars.copy_context()

# Print all context variables and their values in 'ctx':
print(ctx.items())

# Print the value of 'some_variable' in context 'ctx':
print(ctx[some_variable])

请注意,所有 Mapping 方法,包括Context.__getitem__Context.get,都忽略上下文变量的默认值(即ContextVar.default)。这意味着对于使用默认值创建并在上下文中未设置的变量var

  • context[var]会引发KeyError
  • var in context返回False
  • 该变量不包含在context.items()等中。

asyncio

asyncio使用Loop.call_soon()Loop.call_later()Loop.call_at()来安排函数的异步执行。asyncio.Task使用call_soon()来运行包装的协程。

我们修改了Loop.call_{at,later,soon}Future.add_done_callback()以接受新的可选context关键字参数,该参数默认为当前上下文。

def call_soon(self, callback, *args, context=None):
    if context is None:
        context = contextvars.copy_context()

    # ... some time later
    context.run(callback, *args)

asyncio中的任务需要维护自己的上下文,这些上下文是从创建它们的点继承的。asyncio.Task修改如下。

class Task:
    def __init__(self, coro):
        ...
        # Get the current context snapshot.
        self._context = contextvars.copy_context()
        self._loop.call_soon(self._step, context=self._context)

    def _step(self, exc=None):
        ...
        # Every advance of the wrapped coroutine is done in
        # the task's context.
        self._loop.call_soon(self._step, context=self._context)
        ...

实现

本节解释了伪代码中的高级实现细节。为了使本节简短明了,省略了一些优化。

Context映射使用不可变字典实现。这允许对copy_context()函数进行O(1)实现。参考实现使用哈希数组映射尝试(HAMT)实现不可变字典;有关HAMT性能的分析,请参阅PEP 550[1]

出于本节的目的,我们使用写时复制方法和内置的dict类型实现不可变字典。

class _ContextData:

    def __init__(self):
        self._mapping = dict()

    def __getitem__(self, key):
        return self._mapping[key]

    def __contains__(self, key):
        return key in self._mapping

    def __len__(self):
        return len(self._mapping)

    def __iter__(self):
        return iter(self._mapping)

    def set(self, key, value):
        copy = _ContextData()
        copy._mapping = self._mapping.copy()
        copy._mapping[key] = value
        return copy

    def delete(self, key):
        copy = _ContextData()
        copy._mapping = self._mapping.copy()
        del copy._mapping[key]
        return copy

每个操作系统线程都对当前Context对象有一个引用。

class PyThreadState:
    context: Context

contextvars.Context_ContextData的包装器。

class Context(collections.abc.Mapping):

    _data: _ContextData
    _prev_context: Optional[Context]

    def __init__(self):
        self._data = _ContextData()
        self._prev_context = None

    def run(self, callable, *args, **kwargs):
        if self._prev_context is not None:
            raise RuntimeError(
                f'cannot enter context: {self} is already entered')

        ts: PyThreadState = PyThreadState_Get()
        self._prev_context = ts.context
        try:
            ts.context = self
            return callable(*args, **kwargs)
        finally:
            ts.context = self._prev_context
            self._prev_context = None

    def copy(self):
        new = Context()
        new._data = self._data
        return new

    # Implement abstract Mapping.__getitem__
    def __getitem__(self, var):
        return self._data[var]

    # Implement abstract Mapping.__contains__
    def __contains__(self, var):
        return var in self._data

    # Implement abstract Mapping.__len__
    def __len__(self):
        return len(self._data)

    # Implement abstract Mapping.__iter__
    def __iter__(self):
        return iter(self._data)

    # The rest of the Mapping methods are implemented
    # by collections.abc.Mapping.

contextvars.copy_context()实现如下。

def copy_context():
    ts: PyThreadState = PyThreadState_Get()
    return ts.context.copy()

contextvars.ContextVar直接与PyThreadState.context交互。

class ContextVar:

    def __init__(self, name, *, default=_NO_DEFAULT):
        self._name = name
        self._default = default

    @property
    def name(self):
        return self._name

    def get(self, default=_NO_DEFAULT):
        ts: PyThreadState = PyThreadState_Get()
        try:
            return ts.context[self]
        except KeyError:
            pass

        if default is not _NO_DEFAULT:
            return default

        if self._default is not _NO_DEFAULT:
            return self._default

        raise LookupError

    def set(self, value):
        ts: PyThreadState = PyThreadState_Get()

        data: _ContextData = ts.context._data
        try:
            old_value = data[self]
        except KeyError:
            old_value = Token.MISSING

        updated_data = data.set(self, value)
        ts.context._data = updated_data
        return Token(ts.context, self, old_value)

    def reset(self, token):
        if token._used:
            raise RuntimeError("Token has already been used once")

        if token._var is not self:
            raise ValueError(
                "Token was created by a different ContextVar")

        ts: PyThreadState = PyThreadState_Get()
        if token._context is not ts.context:
            raise ValueError(
                "Token was created in a different Context")

        if token._old_value is Token.MISSING:
            ts.context._data = ts.context._data.delete(token._var)
        else:
            ts.context._data = ts.context._data.set(token._var,
                                                    token._old_value)

        token._used = True

请注意,在参考实现中,ContextVar.get()有一个用于最近值的内部缓存,这允许绕过哈希查找。这类似于decimal模块从PyThreadState_GetDict()检索其上下文的优化。请参阅PEP 550,其中详细解释了缓存的实现。

Token类实现如下。

class Token:

    MISSING = object()

    def __init__(self, context, var, old_value):
        self._context = context
        self._var = var
        self._old_value = old_value
        self._used = False

    @property
    def var(self):
        return self._var

    @property
    def old_value(self):
        return self._old_value

新 API 摘要

Python API

  1. 一个新的contextvars模块,其中包含ContextVarContextToken类,以及一个copy_context()函数。
  2. asyncio.Loop.call_at()asyncio.Loop.call_later()asyncio.Loop.call_soon()asyncio.Future.add_done_callback()在调用它们的上下文中运行回调函数。可以使用新的context关键字参数来指定自定义上下文。
  3. asyncio.Task在内部修改以维护其自己的上下文。

C API

  1. PyObject * PyContextVar_New(char *name, PyObject *default):创建一个ContextVar对象。default参数可以是NULL,这意味着该变量没有默认值。
  2. int PyContextVar_Get(PyObject *, PyObject *default_value, PyObject **value):如果在查找过程中发生错误,则返回-1,否则返回0。如果找到上下文变量的值,则将其设置为value指针。否则,当default_value不为NULL时,value将设置为default_value。如果default_valueNULL,则value将设置为变量的默认值,该值也可以为NULLvalue始终是新的引用。
  3. PyObject * PyContextVar_Set(PyObject *, PyObject *):设置当前上下文中变量的值。
  4. PyContextVar_Reset(PyObject *, PyObject *):重置上下文变量的值。
  5. PyObject * PyContext_New():创建一个新的空上下文。
  6. PyObject * PyContext_Copy(PyObject *):返回传递的上下文对象的浅拷贝。
  7. PyObject * PyContext_CopyCurrent():获取当前上下文的副本。
  8. int PyContext_Enter(PyObject *)int PyContext_Exit(PyObject *)允许设置和恢复当前操作系统线程的上下文。始终需要恢复之前的上下文。
    PyObject *old_ctx = PyContext_Copy();
    if (old_ctx == NULL) goto error;
    
    if (PyContext_Enter(new_ctx)) goto error;
    
    // run some code
    
    if (PyContext_Exit(old_ctx)) goto error;
    

被拒绝的想法

复制 threading.local() 接口

请参阅PEP 550,其中详细介绍了此主题[2]

用 ContextVar.unset() 替换 Token

Token API允许绕过使用ContextVar.unset()方法,该方法与PEP 550的链式上下文设计不兼容。希望将来与PEP 550兼容,以防需要在生成器和异步生成器中支持上下文变量。

Token API还提供了更好的可用性:用户不必特殊处理值不存在的情况。比较

token = cv.set(new_value)
try:
    # cv.get() is new_value
finally:
    cv.reset(token)

_deleted = object()
old = cv.get(default=_deleted)
try:
    cv.set(blah)
    # code
finally:
    if old is _deleted:
        cv.unset()
    else:
        cv.set(old)

使用 Token.reset() 而不是 ContextVar.reset()

Nathaniel Smith建议直接在Token类上实现ContextVar.reset()方法,因此,而不是

token = var.set(value)
# ...
var.reset(token)

我们将编写

token = var.set(value)
# ...
token.reset()

拥有Token.reset()将使用户无法尝试使用另一个变量创建的令牌对象重置变量。

此提议被拒绝的原因是ContextVar.reset()对于代码的人类读者来说,更清楚地表明正在重置哪个变量。

使 Context 对象可腌制

由Antoine Pitrou提出,这可以启用Context对象的透明跨进程使用,因此将执行卸载到其他线程示例也可以与ProcessPoolExecutor一起使用。

启用此功能存在以下问题

  1. ContextVar对象没有__module____qualname__属性,这使得Context对象的直接pickle变得不可能。这可以通过修改API来解决,方法是自动检测定义上下文变量的模块,或者向ContextVar构造函数添加新的关键字参数“module”。
  2. 并非所有上下文变量都引用可pickle的对象。使ContextVar可pickle必须是选择加入的。

鉴于Python 3.7发布计划的时间范围,决定将此提议推迟到Python 3.8。

使 Context 成为 MutableMapping

使Context类实现abc.MutableMapping接口意味着可以使用Context[var] = valuedel Context[var]操作来设置和取消设置变量。

此提议由于以下原因被推迟到Python 3.8+。

  1. 如果在Python 3.8中决定生成器应该支持上下文变量(请参阅PEP 550PEP 568),那么Context将转换为上下文变量映射的链式映射(因为每个生成器都将有自己的映射)。这将使像Context.__delitem__这样的变异操作变得令人困惑,因为它们只会对链的最顶层映射进行操作。

  2. 只有一种修改上下文的方式(ContextVar.set()ContextVar.reset() 方法)使 API 更直观。

    例如,以下代码片段为何无法按预期工作并不明显

    var = ContextVar('var')
    
    ctx = copy_context()
    ctx[var] = 'value'
    print(ctx[var])  # Prints 'value'
    
    print(var.get())  # Raises a LookupError
    

    而以下代码则可以正常工作

    ctx = copy_context()
    
    def func():
        ctx[var] = 'value'
    
        # Contrary to the previous example, this would work
        # because 'func()' is running within 'ctx'.
        print(ctx[var])
        print(var.get())
    
    ctx.run(func)
    
  3. 如果 Context 是可变的,则意味着上下文变量可以与在上下文中运行的代码分开(或并发)地进行修改。这类似于获取对正在运行的 Python 帧对象的引用并从另一个操作系统线程修改其 f_locals。只有一种方法为上下文变量赋值,这使得上下文在概念上更简单、更可预测,同时为未来的性能优化留下了空间。

为 ContextVars 设置初始值

Nathaniel Smith 建议为 ContextVar 构造函数添加一个必需的 initial_value 关键字参数。

反对该提议的主要论点是,对于某些类型,除了 None 之外,根本没有合理的“初始值”。例如,考虑一个 Web 框架,它在上下文变量中存储当前的 HTTP 请求对象。使用当前的语义,可以创建一个没有默认值的上下文变量

# Framework:
current_request: ContextVar[Request] = \
    ContextVar('current_request')


# Later, while handling an HTTP request:
request: Request = current_request.get()

# Work with the 'request' object:
return request.method

请注意,在上面的示例中,无需检查 request 是否为 None。框架始终设置 current_request 变量,或者这是一个错误(在这种情况下,current_request.get() 将引发 LookupError)。

但是,如果我们有一个必需的初始值,则必须显式地防止 None

# Framework:
current_request: ContextVar[Optional[Request]] = \
    ContextVar('current_request', initial_value=None)


# Later, while handling an HTTP request:
request: Optional[Request] = current_request.get()

# Check if the current request object was set:
if request is None:
    raise RuntimeError

# Work with the 'request' object:
return request.method

此外,我们可以将上下文变量与常规 Python 变量和 threading.local() 对象进行松散比较。两者在查找失败时都会引发错误(分别为 NameErrorAttributeError)。

向后兼容性

此提议保留了 100% 的向后兼容性。

目前,使用 threading.local() 存储上下文相关值的库仅对同步代码正确工作。将它们切换为使用提议的 API 将使其同步代码的行为保持不变,但会自动启用对异步代码的支持。

示例

转换使用 threading.local() 的代码

使用 threading.local() 的典型代码片段通常如下所示

class PrecisionStorage(threading.local):
    # Subclass threading.local to specify a default value.
    value = 0.0

precision = PrecisionStorage()

# To set a new precision:
precision.value = 0.5

# To read the current precision:
print(precision.value)

此代码可以转换为使用 contextvars 模块

precision = contextvars.ContextVar('precision', default=0.0)

# To set a new precision:
precision.set(0.5)

# To read the current precision:
print(precision.get())

将执行卸载到其他线程

可以使用当前线程上下文的副本在单独的操作系统线程中运行代码

executor = ThreadPoolExecutor()
current_context = contextvars.copy_context()

executor.submit(current_context.run, some_function)

参考实现

参考实现可以在此处找到:[3]。另请参阅问题 32436 [4]

验收

PEP 567 于 2018 年 1 月 22 日星期一由 Guido 接受 [5]。参考实现在同一天合并。

参考文献

致谢

感谢 Guido van Rossum、Nathaniel Smith、Victor Stinner、Elvis Pranskevichus、Alyssa Coghlan、Antoine Pitrou、INADA Naoki、Paul Moore、Eric Snow、Greg Ewing 和许多其他人提供的反馈、想法、编辑、批评、代码审查以及围绕此 PEP 的讨论。


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

上次修改时间:2023-10-11 12:05:51 GMT