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

Python 增强提案

PEP 419 – 保护清理语句免受中断

作者:
Paul Colomiets <paul at colomiets.name>
状态:
推迟
类型:
标准跟踪
创建日期:
2012年4月6日
Python 版本:
3.3

目录

摘要

本 PEP 提出了一种保护 Python 代码在 finally 子句内部或上下文管理器清理期间免受中断的方法。

PEP 延期

对本 PEP 中涵盖概念的进一步探索已被推迟,因为目前缺乏一位有兴趣推广 PEP 目标、收集和整合反馈,并有足够可用时间有效完成此任务的倡导者。

基本原理

Python 有两种很好的清理方法。一种是 finally 语句,另一种是上下文管理器(通常使用 with 语句调用)。但是,两者都不能免受 KeyboardInterrupt 或由 generator.throw() 引起的 GeneratorExit 的中断。例如

lock.acquire()
try:
    print('starting')
    do_something()
finally:
    print('finished')
    lock.release()

如果在第二次 print() 调用之后立即发生 KeyboardInterrupt,则锁将不会被释放。类似地,以下使用 with 语句的代码也会受到影响

from threading import Lock

class MyLock:

    def __init__(self):
        self._lock_impl = Lock()

    def __enter__(self):
        self._lock_impl.acquire()
        print("LOCKED")

    def __exit__(self):
        print("UNLOCKING")
        self._lock_impl.release()

lock = MyLock()
with lock:
    do_something

如果在任何 print() 调用附近发生 KeyboardInterrupt,则锁将永远不会被释放。

协程用例

协程也会出现类似的情况。通常,协程库希望通过超时中断协程。generator.throw() 方法适用于此用例,但无法知道协程当前是否在 finally 子句内部暂停。

下面是一个使用基于 yield 的协程的示例。使用任何流行的协程库 Monocle [1]、Bluelet [2] 或 Twisted [3],代码看起来相似。

def run_locked():
    yield connection.sendall('LOCK')
    try:
        yield do_something()
        yield do_something_else()
    finally:
        yield connection.sendall('UNLOCK')

with timeout(5):
    yield run_locked()

在上面的示例中,yield something 意味着暂停执行当前协程并执行协程 something 直到它完成执行。因此,协程库本身需要维护一个生成器栈。connection.sendall() 调用会等待套接字可写,并执行类似于 socket.sendall() 的操作。

with 语句确保所有代码在 5 秒超时内执行。它通过在主循环中注册一个回调来实现,当超时发生时,该回调会在协程栈中最顶层的帧上调用 generator.throw()

greenlets 扩展以类似的方式工作,只是它不需要 yield 来进入新的栈帧。否则,考虑因素类似。

规范

帧标志“f_in_cleanup”

建议在帧对象上添加一个新标志。如果此帧当前正在执行 finally 子句,则将其设置为 True。在内部,该标志必须实现为当前正在执行的嵌套 finally 语句的计数器。

内部计数器还需要在执行 SETUP_WITHWITH_CLEANUP 字节码期间递增,并在这些字节码执行完成后递减。这允许也保护 __enter__()__exit__() 方法。

函数“sys.setcleanuphook”

建议为 sys 模块添加一个新函数。此函数设置一个回调,每当 f_in_cleanup 变为 false 时执行。回调将帧对象作为其唯一参数,以便它们可以找出从何处调用。

该设置是线程本地的,并且必须存储在 PyThreadState 结构中。

Inspect 模块增强

建议为 inspect 模块添加两个新函数:isframeincleanup()getcleanupframe()

isframeincleanup() 接受一个帧或生成器对象作为其唯一参数,返回帧本身的 f_in_cleanup 属性值或生成器的 gi_frame 属性值。

getcleanupframe() 接受一个帧对象作为其唯一参数,返回具有 f_in_cleanup 真值的最内层帧,如果栈中没有帧的该属性具有非零值,则返回 None。它从指定的帧开始检查,并使用 f_back 指针向上遍历外层帧,就像 getouterframes() 所做的那样。

示例

一个安全中断的 SIGINT 处理程序的示例实现可能如下所示

import inspect, sys, functools

def sigint_handler(sig, frame):
    if inspect.getcleanupframe(frame) is None:
        raise KeyboardInterrupt()
    sys.setcleanuphook(functools.partial(sigint_handler, 0))

协程示例超出本文档范围,因为其实现很大程度上取决于协程库使用的 trampoline(或主循环)。

未解决的问题

With 语句表达式内的中断

鉴于该语句

with open(filename):
    do_something()

Python 可以在调用 open() 之后但在执行 SETUP_WITH 字节码之前中断。有两种可能的决定

  • 保护 with 表达式。这将需要另一个字节码,因为目前无法识别 with 表达式的开始。
  • 如果用户认为这对于用例很重要,则让用户编写一个包装器。一个安全的包装器可能看起来像这样
    class FileWrapper(object):
    
        def __init__(self, filename, mode):
            self.filename = filename
            self.mode = mode
    
        def __enter__(self):
            self.file = open(self.filename, self.mode)
    
        def __exit__(self):
            self.file.close()
    

    或者,它可以使用 contextmanager() 装饰器编写

    @contextmanager
    def open_wrapper(filename, mode):
        file = open(filename, mode)
        try:
            yield file
        finally:
            file.close()
    

    此代码是安全的,因为生成器的第一部分(在 yield 之前)在调用者的 SETUP_WITH 字节码内部执行。

异常传播

有时 finally 子句或 __enter__()/__exit__() 方法可能会引发异常。通常这不成问题,因为应该引发更重要的异常,如 KeyboardInterruptSystemExit。但能够将原始异常保留在 __context__ 属性中可能会很好。因此清理钩子签名可能会增加一个异常参数

def sigint_handler(sig, frame)
    if inspect.getcleanupframe(frame) is None:
        raise KeyboardInterrupt()
    sys.setcleanuphook(retry_sigint)

def retry_sigint(frame, exception=None):
    if inspect.getcleanupframe(frame) is None:
        raise KeyboardInterrupt() from exception

注意

由于 Python 3 中异常具有 __traceback__ 属性,因此不需要像 __exit__ 方法那样有三个参数。

但是,这会为异常设置 __cause__,这与预期不完全相同。因此,可能会使用一些隐藏的解释器逻辑将 __context__ 属性添加到在清理钩子中引发的每个异常。

获取资源和 Try 块之间的中断

第一节中的示例并不完全安全。让我们仔细看看

lock.acquire()
try:
    do_something()
finally:
    lock.release()

问题可能发生在 lock.acquire() 执行之后但在进入 try 块之前中断代码时。

无法在不修改的情况下修复代码。实际的修复很大程度上取决于用例。通常可以使用 with 语句修复代码

with lock:
    do_something()

然而,对于协程,通常不能使用 with 语句,因为您需要为获取和释放操作都 yield。因此代码可能需要这样重写

try:
    yield lock.acquire()
    do_something()
finally:
    yield lock.release()

实际的锁定代码可能需要更多代码来支持此用例,但实现通常很简单,例如:检查锁是否已获取,如果已获取则解锁。

Finally 内部处理 EINTR

即使信号处理程序已准备好检查 f_in_cleanup 标志,也可能在清理处理程序中引发 InterruptedError,因为相应的系统调用返回了 EINTR 错误。主要用例已准备好处理此问题

  • Posix 互斥锁从不返回 EINTR
  • 网络库始终准备好处理 EINTR
  • 协程库通常使用 throw() 方法而不是信号中断

平台特定的函数 siginterrupt() 可用于消除处理 EINTR 的需要。然而,它可能会带来难以预测的后果,例如,如果主线程卡在 IO 例程中,则 SIGINT 处理程序永远不会被调用。

更好的方法是让通常用于清理处理程序中的代码明确地准备好处理 InterruptedError。此类代码的一个示例可能是基于文件的锁实现。

signal.pthread_sigmask 可用于在清理处理程序中阻塞可能被 EINTR 中断的信号。

在 Finally 内部设置中断上下文

某些协程库可能需要为 finally 子句本身设置超时。例如

try:
    do_something()
finally:
    with timeout(0.5):
        try:
            yield do_slow_cleanup()
        finally:
            yield do_fast_cleanup()

在当前语义下,超时将保护整个 with 块或根本不保护,具体取决于每个库的实现。作者的意图是将 do_slow_cleanup 视为普通代码,将 do_fast_cleanup 视为清理(不可中断的清理)。

在使用 greenlet 或 tasklet 时也可能出现类似情况。

这种情况可以通过将 f_in_cleanup 公开为计数器,并在每次递减时调用清理钩子来解决。协程库然后可以在超时开始时记住该值,并在每次钩子执行时进行比较。

但在实践中,这个例子被认为过于晦涩,不值得考虑。

修改 KeyboardInterrupt

应该决定是否修改默认的 SIGINT 处理程序以使用所描述的机制。最初的提议是保持旧行为,原因有二

  • 大多数应用程序不关心退出时的清理(它们要么没有外部状态,要么以防崩溃的方式修改它)。
  • 清理可能需要太长时间,用户没有机会中断应用程序。

后一种情况可以通过允许在调用 SIGINT 处理程序两次时进行不安全中断来解决,但这似乎不值得增加复杂性。

替代 Python 实现支持

我们将 f_in_cleanup 视为实现细节。实际实现可能会有一些假的类似帧的对象传递给信号处理程序、清理钩子,并从 getcleanupframe() 返回。唯一的要求是 inspect 模块函数在这些对象上按预期工作。因此,我们还允许将生成器对象传递给 isframeincleanup() 函数,这消除了使用 gi_frame 属性的需要。

可能需要指定 getcleanupframe() 必须返回与下次调用时传递给清理钩子的对象相同的对象。

替代名称

最初的提案有一个 f_in_finally 帧属性,因为最初的目的是保护 finally 子句。但随着它也发展到保护 __enter____exit__ 方法,f_in_cleanup 名称似乎更好。尽管 __enter__ 方法不是清理例程,但它至少与上下文管理器完成的清理相关。

setcleanuphookisframeincleanupgetcleanupframe 可以被澄清为 set_cleanup_hookis_frame_in_cleanupget_cleanup_frame,尽管它们遵循各自模块的命名约定。

替代方案

自动传播“f_in_cleanup”标志

这可以使 getcleanupframe() 不必要。但对于基于 yield 的协程,您需要自己传播它。使其可写会导致 setcleanuphook() 出现某种不可预测的行为。

添加字节码“INCR_CLEANUP”,“DECR_CLEANUP”

这些字节码可用于保护 with 语句内的表达式,并使计数器递增更明确且易于调试(在反汇编中可见)。可以采用一些折衷方案,例如 END_FINALLYSETUP_WITH 隐式递减计数器(END_FINALLY 存在于每个 with 套件的末尾)。

但是,添加新字节码必须非常谨慎。

将“f_in_cleanup”公开为计数器

最初的意图是公开最少所需的功能。然而,由于我们将帧标志 f_in_cleanup 视为实现细节,我们可以将其公开为计数器。

同样,如果有一个计数器,我们可能需要在每次计数器递减时调用清理钩子。由于嵌套的 finally 子句不常见,因此不太可能对性能产生太大影响。

添加代码对象标志“CO_CLEANUP”

作为在 SETUP_WITHWITH_CLEANUP 字节码中设置标志的替代方案,我们可以引入一个标志 CO_CLEANUP。当解释器开始执行设置了 CO_CLEANUP 的代码时,它会为整个函数体设置 f_in_cleanup。此标志为 __enter____exit__ 特殊方法的代码对象设置。从技术上讲,它可能在名为 __enter____exit__ 的函数上设置。

这似乎是一个不太清晰的解决方案。它还涵盖了手动调用 __enter____exit__ 的情况。这可能被接受为功能或不必要的副作用(或者,尽管不太可能,被视为错误)。

__enter____exit__ 函数用 C 语言实现时,也可能带来问题,因为没有代码对象可供检查 f_in_cleanup 标志。

在帧对象本身上进行清理回调

帧对象可以扩展以包含 f_cleanup_callback 成员,该成员在 f_in_cleanup 重置为 0 时调用。这将有助于为不同的协程注册不同的回调。

尽管其表面上很美观,但此解决方案并未添加任何内容,因为两个主要用例是

  • 在信号处理程序中设置回调。在这种情况下,回调本质上是单个的。
  • 在协程用例中,每个循环使用一个回调。在这里,几乎所有情况下,每个线程只有一个循环。

无清理钩子

最初的提案没有包含清理钩子规范,因为有几种方法可以使用当前工具实现相同的效果

  • 使用 sys.settrace()f_trace 回调。这可能会给调试带来一些问题,并且对性能影响很大(尽管中断不经常发生)。
  • 多睡一会儿再试一次。对于协程库来说这很容易。对于信号,可以使用 signal.alert 实现。

这两种方法都被认为不切实际,因此提出了一种捕获 finally 子句退出情况的方法。

参考资料

[4] 原始讨论 https://mail.python.org/pipermail/python-ideas/2012-April/014705.html

[5] PEP 419 的实现 https://github.com/python/cpython/issues/58935


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

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