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_WITH
和 WITH_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__()
方法可能会引发异常。通常这不成问题,因为应该引发更重要的异常,如 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
注意
由于 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__
方法不是清理例程,但它至少与上下文管理器完成的清理相关。
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