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);
基本原理
线程本地变量不足以满足在同一操作系统线程中并发执行的异步任务。任何使用 threading.local()
保存和恢复上下文值的上下文管理器,当在 async/await 代码中使用时,其上下文值将意外地泄漏到其他代码。
一些希望为异步代码提供有效的上下文本地存储的示例
- 像
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
函数用于获取当前操作系统线程的当前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
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
- 一个新的
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兼容,以防需要在生成器和异步生成器中支持上下文变量。
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
一起使用。
启用此功能存在以下问题
ContextVar
对象没有__module__
和__qualname__
属性,这使得Context
对象的直接pickle变得不可能。这可以通过修改API来解决,方法是自动检测定义上下文变量的模块,或者向ContextVar
构造函数添加新的关键字参数“module”。- 并非所有上下文变量都引用可pickle的对象。使
ContextVar
可pickle必须是选择加入的。
鉴于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
之外,根本没有合理的“初始值”。例如,考虑一个 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()
对象进行松散比较。两者在查找失败时都会引发错误(分别为 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)
参考实现
验收
参考文献
致谢
感谢 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