PEP 567 – 上下文变量
- 作者:
- Yury Selivanov <yury at edgedb.com>
- 状态:
- 最终版
- 类型:
- 标准跟踪
- 创建日期:
- 2017年12月12日
- Python 版本:
- 3.7
- 发布历史:
- 2017年12月12日,2017年12月28日,2018年1月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);
基本原理
线程局部变量不足以处理在同一操作系统线程中并发执行的异步任务。任何使用 threading.local() 保存和恢复上下文值的上下文管理器,在异步/等待代码中使用时,其上下文值将意外地泄露给其他代码。
以下是一些需要为异步代码提供可工作的上下文局部存储的例子:
- 像
decimal上下文和numpy.errstate这样的上下文管理器。 - 与请求相关的数据,例如 web 应用程序中的安全令牌和请求数据,
gettext的语言上下文等。 - 大型代码库中的性能分析、跟踪和日志记录。
引言
本 PEP 提出了一种管理上下文变量的新机制。此机制涉及的关键类是 contextvars.Context 和 contextvars.ContextVar。本 PEP 还提出了一些围绕异步任务使用此机制的策略。
访问上下文变量的拟议机制使用 ContextVar 类。希望使用新机制的模块(例如 decimal)应该
- 声明一个持有
ContextVar的模块全局变量作为键; - 通过键变量上的
get()方法访问当前值; - 通过键变量上的
set()方法修改当前值。
“当前值” 的概念值得特别考虑:存在并同时执行的不同异步任务可能对同一个键具有不同的值。这个想法从线程局部存储中广为人知,但在这种情况下,值的局部性不一定与线程绑定。相反,存在 “当前 Context” 的概念,它存储在线程局部存储中。当前上下文的操作是任务框架的责任,例如 asyncio。
一个 Context 是 ContextVar 对象到其值的映射。 Context 本身公开了 abc.Mapping 接口(而不是 abc.MutableMapping!),因此它不能直接修改。要在 Context 对象中为上下文变量设置新值,用户需要
- 使用
Context.run()方法使Context对象“当前”; - 使用
ContextVar.set()为上下文变量设置新值。
ContextVar.get() 方法使用 self 作为键在当前 Context 对象中查找变量。
无法直接引用当前 Context 对象,但可以使用 contextvars.copy_context() 函数获取其浅拷贝。这确保了 Context.run() 的 调用者 是其 Context 对象的唯一所有者。
规范
一个新的标准库模块 contextvars 已添加,包含以下 API:
copy_context() -> Context函数用于获取当前 OS 线程的当前Context对象的副本。ContextVar类用于声明和访问上下文变量。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) 用于将当前上下文中的变量重置为创建 token 的 set() 操作之前的值(或者如果变量未设置,则将其删除)
# 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 抽象基类。这可以用于自省上下文。
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])
请注意,所有映射方法,包括 Context.__getitem__ 和 Context.get,都忽略上下文变量的默认值(即 ContextVar.default)。这意味着对于一个用默认值创建但在 context 中未设置的变量 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
- 一个新的
contextvars模块,包含ContextVar、Context和Token类,以及一个copy_context()函数。 asyncio.Loop.call_at()、asyncio.Loop.call_later()、asyncio.Loop.call_soon()和asyncio.Future.add_done_callback()在其被调用的上下文中运行回调函数。可以使用新的 context 仅限关键字参数来指定自定义上下文。asyncio.Task在内部进行了修改,以维护其自己的上下文。
C API
PyObject * PyContextVar_New(char *name, PyObject *default):创建ContextVar对象。default 参数可以是NULL,这意味着该变量没有默认值。int PyContextVar_Get(PyObject *, PyObject *default_value, PyObject **value):如果在查找过程中发生错误,则返回-1,否则返回0。如果找到了上下文变量的值,它将被设置为value指针。否则,当default_value不为NULL时,value将被设置为default_value。如果default_value为NULL,value将被设置为变量的默认值,该值也可以是NULL。value始终是新引用。PyObject * PyContextVar_Set(PyObject *, PyObject *):设置当前上下文中变量的值。PyContextVar_Reset(PyObject *, PyObject *):重置上下文变量的值。PyObject * PyContext_New():创建一个新的空上下文。PyObject * PyContext_Copy(PyObject *):返回传入上下文对象的浅拷贝。PyObject * PyContext_CopyCurrent():获取当前上下文的副本。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() 接口
用 ContextVar.unset() 替换 Token
Token API 避免了 ContextVar.unset() 方法,该方法与 PEP 550 的链式上下文设计不兼容。如果将来需要支持生成器和异步生成器中的上下文变量,则希望与 PEP 550 保持兼容性。
令牌 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 一起工作。
启用此功能存在问题,原因如下:
ContextVar对象没有__module__和__qualname__属性,使得Context对象的直接序列化不可能。这可以通过修改 API 来解决,方法是自动检测上下文变量定义的模块,或者向ContextVar构造函数添加新的仅限关键字参数“module”。- 并非所有上下文变量都引用可序列化对象。使
ContextVar可序列化必须是选择加入的。
考虑到 Python 3.7 发布计划的时间框架,决定将此提案推迟到 Python 3.8。
使 Context 成为 MutableMapping
使 Context 类实现 abc.MutableMapping 接口将意味着可以使用 Context[var] = value 和 del Context[var] 操作来设置和取消设置变量。
此提案因以下原因推迟到 Python 3.8+:
- 如果在 Python 3.8 中决定生成器应该支持上下文变量(参见 PEP 550 和 PEP 568),那么
Context将被转换为上下文变量映射的链式映射(因为每个生成器都将拥有自己的映射)。这将使诸如Context.__delitem__之类的变异操作变得令人困惑,因为它们只对链中的最顶层映射进行操作。 - 只有一种方式来修改上下文(
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)
- 如果
Context是可变的,则意味着上下文变量可以独立于(或并发地)在上下文中运行的代码进行修改。这类似于获取对正在运行的 Python 帧对象的引用并从另一个操作系统线程修改其f_locals。采用单一方式将值分配给上下文变量使上下文在概念上更简单、更可预测,同时为未来的性能优化留下了可能性。
为 ContextVars 设置初始值
Nathaniel Smith 建议为 ContextVar 构造函数提供一个必需的 initial_value 仅限关键字参数。
反对该提案的主要论点是,对于某些类型,除了 None 之外,根本没有合理的“初始值”。例如,考虑一个将当前 HTTP 请求对象存储在上下文变量中的 Web 框架。在当前的语义下,可以创建一个没有默认值的上下文变量:
# 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() 对象进行比较。它们都在查找失败时引发错误(分别为 NameError 和 AttributeError)。
向后兼容性
本提案保留 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)
参考实现
接受
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