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()

如果 KeyboardInterrupt 恰好在第二个 print() 调用之后发生,则不会释放锁。类似地,以下使用 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

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

协程用例

协程也会出现类似的情况。通常协程库希望使用超时来中断协程。 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() 以帧或生成器对象作为其唯一参数,返回帧本身或生成器的 gi_frame 属性的 f_in_cleanup 属性的值。

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))

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

未解决的问题

在 with 语句表达式内部中断

给定语句

with open(filename):
    do_something()

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

  • 保护 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

注意

不需要像 __exit__ 方法那样有三个参数,因为 Python 3 中的异常有一个 __traceback__ 属性。

但是,这将为异常设置 __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 mutex 从不返回 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 视为清理操作(不可中断的操作)。

使用 greenlets 或 tasklets 时也可能出现类似的情况。

可以通过将 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

上次修改:2023-09-09 17:39:29 GMT