问题的历史
在 Python 2.5 通过 PEP 343 引入 with 语句之前,资源管理需要在代码库中散布明确的 try/finally 块。虽然这种模式能够正常工作,但对于简单的资源获取和释放场景来说,这种模式冗长且易出错。为减少这种模板代码,引入了 contextlib 模块,允许开发者将上下文管理器写成生成器函数,通过 @contextmanager 装饰器将看似顺序的生成器转化为符合上下文管理协议的对象。
问题
生成器函数本身实现了迭代器协议(__iter__, __next__),而不是上下文管理器协议(__enter__, __exit__)。基本挑战在于桥接这两种不同的协议:在进入 with 块时,yield 之前的设置代码必须执行;在退出时,不论是否发生异常,yield 之后的清理代码必须运行。此外,在 with 块内引发的异常必须在生成器的确切 yield 挂起点注入,使生成器自身的异常处理逻辑能够执行清理操作。
解决方案
该装饰器将生成器函数包装在一个 GeneratorContextManager 类中(在现代 CPython 中用 C 实现)。每次调用都会创建一个新的生成器迭代器。__enter__ 方法对这个迭代器调用 next(),执行函数直到 yield 语句,并将返回的值绑定到 as 变量上。__exit__ 方法接收异常详情;如果没有异常,则再次调用 next() 以恢复并耗尽生成器。如果发生了异常,则调用生成器的 throw() 方法,在挂起的 yield 点注入异常。这允许生成器的 except 或 finally 块处理清理。如果 throw() 正常返回(捕获了异常),__exit__ 返回 True 以抑制异常;否则,异常会传播。
from contextlib import contextmanager @contextmanager def managed_connection(): conn = create_connection() try: print("连接已建立") yield conn except NetworkError: conn.rollback() raise finally: conn.close() print("连接已关闭") with managed_connection() as c: c.query("SELECT * FROM data")
问题描述: 一个高吞吐量数据处理服务需要处理临时溢出文件,当内存中的缓冲区超过限制时。遗留实现将文件创建和删除逻辑在 12 个不同的处理模块中重复,导致在边缘情况错误条件下文件描述符泄漏并增加了维护的复杂性。
考虑的解决方案:
最初的解决方案是手动的 try/finally 块。在每个使用点将文件操作包装在明确的 try/finally 中,以确保调用 os.unlink()。这提供了明确的控制流,没有抽象开销,但每个使用点需要八行代码,且极易出错。开发人员偶尔会将清理逻辑放在错误的 finally 块中,并且在添加日志要求时,在所有模块中一致地修改行为是困难的。
考虑到一种基于类的上下文管理器作为可重用的替代方案。一种 TempSpillFile 类将实现 __enter__ 来创建文件,__exit__ 来删除它。虽然可重用并遵循标准协议,但类定义在视觉上将设置与清理分开了许多行,影响了可读性。它还需要十五行样板代码,而概念上这仅是一个简单的资源生命周期,掩盖了实际逻辑。
最终选择了使用 @contextmanager 的生成器。一个 temp_spill_file() 生成器函数将创建文件,生成它,并使用 try/finally 进行删除。这最小化了代码重复,并将设置和清理保持在源代码中相邻,利用了熟悉的异常处理语法。然而,这强加了单次使用限制,yield 挂起点可能会让期待同步执行的开发人员感到困惑。
选择的解决方案与结果: 选择了 @contextmanager 方法,因为它最小化了代码重复,同时在代码审查期间最大化了清晰度。获取和释放逻辑的相邻性使资源生命周期立刻显而易见。重构将资源管理代码从九十六行减少到十二行。在随后的一个季度的生产使用中,静态分析确认没有文件描述符泄漏。
GeneratorContextManager 如何处理在设置阶段(yield 之前)与清理阶段(yield 之后)发生的异常?
如果在生成器中 yield 之前发生异常,生成器不会挂起;__enter__ 会立即传播此异常,__exit__ 永远不会被调用。如果在 with 块内(yield 之后)发生异常,生成器处于挂起状态。然后 __exit__ 调用 generator.throw(exc_type, exc_val, exc_tb),这将使用活动异常在 yield 行恢复生成器。这允许生成器自身的 except 或 finally 块执行。候选人经常忽略 throw() 实际上是恢复执行,并且该异常视为在生成器的 yield 表达式处发生。
为什么 contextmanager 装饰的生成器强制执行单个 yield 点,违反此约束时会发生什么特定错误?
上下文管理器协议假定单个进入和退出。如果生成器第二次 yield——要么是因为 __exit__ 调用 next()(无异常)而生成器再次 yield 而不是返回,或者是因为调用了 throw() 并且生成器处理异常后再次 yield——GeneratorContextManager 会引发 RuntimeError,消息为 "生成器没有停止"。这是因为状态机期望在清理后生成器被耗尽。候选人经常将此与标准迭代混淆,在标准迭代中多个 yield 是有效的,而未意识到 yield 作为上下文的挂起/恢复边界,而不仅仅是值生产序列。
在什么情况下,GeneratorContextManager 的 __exit__ 方法抑制在 with 块中引发的异常,该行为如何与生成器的异常处理交互?
__exit__ 仅在通过 throw() 注入的异常在生成器内被捕获且生成器达到其末尾(引发 StopIteration)而未重新引发异常或引发新异常时抑制异常(返回 True)。如果生成器捕获并允许 throw() 调用正常返回,__exit__ 将其视为成功处理并返回 True。如果生成器没有捕获异常,throw() 会将其传播出来,__exit__ 返回 None(假值),允许异常传播。候选人常常忽略仅在生成器内有 try/except 是不够的;异常必须被专门捕获 来自 throw() 调用,并且不能重新引发,并且为了抑制异常,必须显式 return 或在捕获后落在结尾。