PEP 521 – 在生成器和协程中通过“with”块管理全局上下文
- 作者:
- Nathaniel J. Smith <njs at pobox.com>
- 状态:
- 已撤回
- 类型:
- 标准跟踪
- 创建:
- 2015年4月27日
- Python 版本:
- 3.6
- 历史记录:
- 2015年4月29日
PEP 撤回
已撤回,取而代之的是 PEP 567。
摘要
虽然我们通常尽量避免使用全局状态,但仍然存在一些情况,大家一致认为使用全局状态是最佳方法。在 Python 中,处理此类情况的标准模式是将全局状态存储在全局或线程本地存储中,然后使用 with
块将对该全局状态的修改限制到单个动态作用域。使用此模式的示例包括标准库的 warnings.catch_warnings
和 decimal.localcontext
、NumPy 的 numpy.errstate
(它公开 IEEE 754 浮点数标准提供的错误处理设置)以及许多服务器应用程序框架中日志记录上下文或 HTTP 请求上下文的处理。
但是,在编写生成器或协程时,目前没有符合人体工程学的方式来管理对全局状态的此类局部更改。例如,以下代码
def f():
with warnings.catch_warnings():
for x in g():
yield x
可能会也可能不会成功捕获由 g()
引发的警告,并且可能会也可能不会无意中吞并代码其他地方触发的警告。上下文管理器旨在仅应用于 f
及其被调用者,最终其动态作用域包含了其调用者任意且不可预测的部分。在编写异步代码时,这个问题变得尤其严重,因为本质上所有函数都变成了协程。
在这里,我们建议通过在执行在上下文管理器作用域内挂起或恢复时通知上下文管理器来解决此问题,从而允许它们适当地限制其影响。
规范
上下文管理器协议中添加了两种新的可选方法:__suspend__
和 __resume__
。如果存在,这些方法将在框架的执行在 with
块的上下文中挂起或恢复时被调用。
更正式地说,考虑以下代码
with EXPR as VAR:
PARTIAL-BLOCK-1
f((yield foo))
PARTIAL-BLOCK-2
目前这等效于以下代码(从 PEP 343 复制)
mgr = (EXPR)
exit = type(mgr).__exit__ # Not calling it yet
value = type(mgr).__enter__(mgr)
exc = True
try:
try:
VAR = value # Only if "as VAR" is present
PARTIAL-BLOCK-1
f((yield foo))
PARTIAL-BLOCK-2
except:
exc = False
if not exit(mgr, *sys.exc_info()):
raise
finally:
if exc:
exit(mgr, None, None, None)
本 PEP 提出修改 with
块处理,使其变为
mgr = (EXPR)
exit = type(mgr).__exit__ # Not calling it yet
### --- NEW STUFF ---
if the_block_contains_yield_points: # known statically at compile time
suspend = getattr(type(mgr), "__suspend__", lambda: None)
resume = getattr(type(mgr), "__resume__", lambda: None)
### --- END OF NEW STUFF ---
value = type(mgr).__enter__(mgr)
exc = True
try:
try:
VAR = value # Only if "as VAR" is present
PARTIAL-BLOCK-1
### --- NEW STUFF ---
suspend(mgr)
tmp = yield foo
resume(mgr)
f(tmp)
### --- END OF NEW STUFF ---
PARTIAL-BLOCK-2
except:
exc = False
if not exit(mgr, *sys.exc_info()):
raise
finally:
if exc:
exit(mgr, None, None, None)
类似的挂起/恢复调用也围绕嵌入在 yield from
、await
、async with
和 async for
构造中的 yield
点进行包装。
嵌套块
给定以下代码
def f():
with OUTER:
with INNER:
yield VALUE
然后我们按以下顺序执行以下操作
INNER.__suspend__()
OUTER.__suspend__()
yield VALUE
OUTER.__resume__()
INNER.__resume__()
请注意,这确保以下内容是有效的重构
def f():
with OUTER:
yield from g()
def g():
with INNER
yield VALUE
类似地,具有多个上下文管理器的 with
语句从右到左挂起,从左到右恢复。
其他更改
适当的 __suspend__
和 __resume__
方法已添加到 warnings.catch_warnings
和 decimal.localcontext
中。
基本原理
在摘要中,我们给出了一个看似合理但错误的代码示例
def f():
with warnings.catch_warnings():
for x in g():
yield x
为了在当前的 Python 中使其正确,我们需要改写类似以下内容
def f():
with warnings.catch_warnings():
it = iter(g())
while True:
with warnings.catch_warnings():
try:
x = next(it)
except StopIteration:
break
yield x
另一方面,如果本 PEP 被接受,则原始代码将按原样变得正确。或者,如果这没有说服力,那么这里还有一个损坏的代码示例;修复它需要更大的调整,这些调整留作读者的练习
async def test_foo_emits_warning():
with warnings.catch_warnings(record=True) as w:
await foo()
assert len(w) == 1
assert "xyzzy" in w[0].message
并且请注意,最后一个示例一点也不人为——这正是您编写测试以确保使用 async/await 的协程正确引发警告的方式。对于几乎任何使用 warnings.catch_warnings
、decimal.localcontext
或 numpy.errstate
的 async/await 代码,都会出现类似的问题。因此,这里显然需要解决一个真正的问题,并且异步代码的日益突出使得这个问题变得越来越紧迫。
替代方法
已经提出的主要替代方案是创建某种“任务本地存储”,类似于“线程本地存储” [1]。从本质上讲,这个想法是事件循环将负责为其调度的每个任务分配一个新的“任务命名空间”,并提供一个 API 以在任何给定时间获取对应于当前正在执行的任务的命名空间。虽然有许多细节需要解决 [2],但基本想法似乎是可行的,它是一种处理异步应用程序框架顶级出现的全局上下文的特别自然的方式(例如,在 Web 框架中设置上下文对象)。但它也有一些缺陷
- 它只解决了管理对异步事件循环
yield
回的协程的全局状态的问题。但实际上,关于这个问题,没有什么特定于 asyncio 的——如上例所示,简单的生成器会遇到完全相同的问题。 - 它在需要管理全局状态的代码和事件循环之间创建了不必要的耦合。显然,异步 Web 框架无论如何都需要与某些事件循环 API 交互,因此在这种情况下这不是什么大问题。但奇怪的是,
warnings
或decimal
或 NumPy 必须调用异步库的 API 来访问其内部状态,而它们本身不涉及任何异步代码。更糟糕的是,由于有多个常用的事件循环 API,因此不清楚应该选择哪个与之集成。(这可以通过 CPython 提供一个标准 API 来创建和切换“任务本地域”来得到一定程度的缓解,asyncio、Twisted、tornado 等可以与之配合使用。) - 目前尚不清楚这是否可以达到可接受的速度。NumPy 必须在每次算术运算时都检查浮点数错误设置。检查线程本地存储中的数据非常快,因为现代平台投入了大量资源来优化这种情况(例如,为此目的专门分配 CPU 寄存器);调用事件循环上的方法来获取命名空间的句柄,然后在该命名空间中进行查找要慢得多。
更重要的是,即使对于根本没有使用事件循环的程序,也会在每次访问全局数据时支付这笔额外费用。相比之下,本 PEP 的提议仅影响实际混合了
with
块和yield
语句的代码,这意味着体验成本的用户也是获得收益的用户。
另一方面,任务上下文和事件循环之间这种紧密的集成确实有可能允许超出当前提议范围的其他功能。例如,事件循环可以记录任务在调用 call_soon
时处于哪个任务命名空间,并安排回调在运行时可以访问相同的任务命名空间。这是否有用,或者在跨线程调用时是否定义明确(同时从两个线程访问任务本地存储意味着什么?),留作事件循环实现者思考的难题——本提议中没有任何内容排除此类增强。不过,此类功能似乎主要对已经与事件循环紧密集成的状态有用——虽然我们可能希望请求 ID 在 call_soon
中保留,但大多数人不会期望
with warnings.catch_warnings():
loop.call_soon(f)
导致 f
在禁用警告的情况下运行,这将是 call_soon
通常保留全局上下文的结果。鉴于警告上下文管理器 __exit__
将在 f
之前被调用,因此也不清楚这将如何工作。
因此,本 PEP 认为 __suspend__
/__resume__
和“任务本地存储”是两种互补的工具,在不同的情况下都有用。
向后兼容性
因为 __suspend__
和 __resume__
是可选的,并且默认为无操作,所以所有现有的上下文管理器都将继续像以前一样工作。
在速度方面,此提议在进入 with
块时增加了额外的开销(我们现在必须检查其他方法;CPython 中的属性查找失败相当慢,因为它涉及分配 AttributeError
),以及在挂起点增加了额外的开销。由于 with
块和挂起点的所在位置是静态已知的,因此编译器可以简单地优化掉所有情况下的此开销,除了实际上在 with
内部有 yield
的情况。此外,因为我们只在 with
块的开头对 __suspend__
和 __resume__
执行一次属性检查,当这些属性未定义时,每次 yield 的开销可以优化为单个 C 级别的 if (frame->needs_suspend_resume_calls) { ... }
。因此,我们预计总体开销可以忽略不计。
与 PEP 492 的交互
PEP 492 添加了新的异步上下文管理器,它们类似于常规的上下文管理器,但不同于常规方法__enter__
和__exit__
,它们具有协程方法__aenter__
和__aexit__
。
遵循此模式,人们可能会期望此提案添加__asuspend__
和__aresume__
协程方法。但这没有多大意义,因为关键在于__suspend__
应该在我们让出执行线程并允许其他代码运行之前被调用。将__asuspend__
设为协程唯一能实现的就是让__asuspend__
本身能够让出。因此,我们需要在__asuspend__
内部递归调用__asuspend__
,或者放弃并允许这些让出发生而无需调用挂起回调;无论哪种方式,它都会违背整个目的。
好吧,除了一个例外:协程代码的一种可能的模式是调用yield
来与协程运行器通信,但实际上并没有挂起它们的执行(即,协程可能知道协程运行器将在处理完yield
的消息后立即恢复它们)。一个例子是curio.timeout_after
异步上下文管理器,它向curio内核发送一个特殊的set_timeout
消息,然后内核立即(同步地)恢复发送该消息的协程。从用户的角度来看,此超时值的行为就像激发此PEP的全局变量一样。但是,有一个关键的区别:这种异步上下文管理器,从定义上讲,与协程运行器紧密集成。因此,协程运行器可以承担跟踪哪些超时适用于哪些协程的责任,而根本不需要此PEP(这确实是curio.timeout_after的工作方式)。
这留下了两种处理异步上下文管理器的合理方法
- 添加普通的
__suspend__
和__resume__
方法。 - 暂时先不要管异步上下文管理器,直到我们对它们有更多经验。
两种方法似乎都有道理,所以出于懒惰/YAGNI,此PEP暂时建议坚持选择(2)。
参考文献
版权
本文档已置于公共领域。
来源:https://github.com/python/peps/blob/main/peps/pep-0521.rst