Following system colour scheme - Python 增强提案 Selected dark colour scheme - Python 增强提案 Selected light colour scheme - Python 增强提案

Python 增强提案

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_warningsdecimal.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 fromawaitasync withasync 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语句从右到左暂停,从左到右恢复。

其他更改

warnings.catch_warningsdecimal.localcontext中添加了适当的__suspend____resume__方法。

基本原理

在摘要中,我们给出了一个可能但不正确的代码示例

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_warningsdecimal.localcontextnumpy.errstate的使用,都会出现类似的问题。所以这里显然有一个真实的问题需要解决,而异步代码日益突出的重要性使得它变得越来越紧迫。

替代方法

已经提出的主要替代方案是创建某种“任务局部存储”,类似于“线程局部存储”[1]。从本质上讲,这个想法是事件循环会为它调度的每个任务分配一个新的“任务命名空间”,并提供一个API,以便在任何给定时间获取与当前执行任务对应的命名空间。虽然有许多细节需要解决[2],但基本思想似乎可行,而且它是一种特别自然的方式来处理在异步应用程序框架顶层出现的全局上下文(例如,在Web框架中设置上下文对象)。但它也有一些缺陷

  • 它只解决了管理协程全局状态的问题,这些协程yield回异步事件循环。但这个问题实际上并没有任何特定于asyncio的地方——如上面的示例所示,简单的生成器遇到了完全相同的问题。
  • 它在事件循环和需要管理全局状态的代码之间创建了不必要的耦合。显然,一个异步Web框架无论如何都需要与某个事件循环API进行交互,所以在这种情况下这并不是什么大问题。但奇怪的是,warningsdecimal或NumPy应该必须调用异步库的API来访问它们的内部状态,而它们本身不涉及任何异步代码。更糟糕的是,由于有多个常用的事件循环API,尚不清楚如何选择与哪个集成。(这可以通过CPython提供一个用于创建和切换“任务局部域”的标准API来缓解,然后asyncio、Twisted、tornado等可以与此配合。)
  • 目前还不清楚这是否能做到足够快。NumPy必须在每一次算术操作中检查浮点错误设置。检查线程局部存储中的一段数据速度快得惊人,因为现代平台投入了大量资源来优化这种情况(例如,为此目的专用了一个CPU寄存器);调用事件循环上的方法来获取命名空间的句柄,然后在该命名空间中进行查找要慢得多。

    更重要的是,这种额外的开销将在对全局数据的每次访问中支付,即使对于根本不使用事件循环的程序也是如此。相比之下,本PEP的提案仅影响实际混合了with块和yield语句的代码,这意味着承担开销的用户与获得好处的用户是相同的。

另一方面,任务上下文与事件循环之间如此紧密的集成确实可能允许其他超出当前提案范围的功能。例如,事件循环可以记录任务调用call_soon时生效的任务命名空间,并安排回调运行时可以访问相同的任务命名空间。这是否有用,甚至在跨线程调用(从两个线程同时访问任务局部存储意味着什么?)的情况下是否定义明确,都留给事件循环实现者思考——本提案中没有任何内容排除此类增强。不过,此类功能似乎主要对已经与事件循环紧密集成状态有用——虽然我们可能希望在call_soon之后保留请求ID,但大多数人不会期望

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__本身能够yield。所以,要么我们需要在__asuspend__内部递归调用__asuspend__,要么我们需要放弃并允许这些yield发生而不调用suspend回调;无论哪种方式都违背了其目的。

嗯,有一个例外:协程代码的一种可能模式是调用yield来与协程运行器通信,但实际上不暂停其执行(即,协程可能知道协程运行器将在处理yielded消息后立即恢复它)。一个例子是curio.timeout_after异步上下文管理器,它向curio内核yield一个特殊的set_timeout消息,然后内核立即(同步地)恢复发送消息的协程。从用户角度来看,这个超时值就像激发本PEP的全局变量一样。但是,有一个关键区别:这种异步上下文管理器,根据定义,与协程运行器紧密集成。因此,协程运行器可以承担跟踪哪个超时应用于哪个协程的责任,而根本不需要本PEP(这确实是curio.timeout_after的工作方式)。

这给处理异步上下文管理器留下了两种合理的方法

  1. 添加普通的__suspend____resume__方法。
  2. 暂时保留异步上下文管理器,直到我们积累更多经验。

两者都似乎合理,因此出于懒惰 / YAGNI,本PEP暂时建议选择选项(2)。

参考资料


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

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