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_WITH
和 WITH_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__()
方法可能会引发异常。通常这不是问题,因为应该引发更重要的异常,例如 KeyboardInterrupt
或 SystemExit
。但能够将原始异常保留在 __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__
方法不是清理例程,但它至少与上下文管理器执行的清理相关。
setcleanuphook
、isframeincleanup
和 getcleanupframe
可以改为更清晰的 set_cleanup_hook
、is_frame_in_cleanup
和 get_cleanup_frame
,尽管它们遵循各自模块的命名约定。
备选方案
自动传播 'f_in_cleanup' 标志
这可以使 getcleanupframe()
变得不必要。但是对于基于 yield 的协程,您需要自己传播它。使其可写会导致 setcleanuphook()
的行为有些不可预测。
添加字节码 'INCR_CLEANUP'、'DECR_CLEANUP'
这些字节码可用于保护 with
语句内的表达式,以及使计数器增量更明确且易于调试(在反汇编中可见)。可以选择一些折衷方案,例如 END_FINALLY
和 SETUP_WITH
隐式递减计数器(END_FINALLY
存在于每个 with
代码块的末尾)。
但是,添加新的字节码必须非常谨慎地考虑。
将 'f_in_cleanup' 暴露为计数器
最初的意图是公开最少的必要功能。但是,由于我们将帧标志 f_in_cleanup
视为实现细节,因此我们可能会将其公开为计数器。
同样,如果我们有一个计数器,我们可能需要在每次计数器递减时调用清理钩子。嵌套的 finally 子句很少见,因此不太可能对性能产生重大影响。
添加代码对象标志 'CO_CLEANUP'
作为在 SETUP_WITH
和 WITH_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