Chapter 7: Context Management Semantics
with 语句把资源生命周期写进 Python 运行时协议:进入代码块前获取资源,离开代码块时释放资源,并把异常状态交给资源对象判断。读完本章后,读者应能追踪一个 with 或 async with 片段的进入顺序、绑定对象、异常传播、退出顺序和资源释放边界。
本章的贯穿材料是一个短资源作用域。它记录进入、退出和异常信息,用来观察 context manager 在普通完成、异常完成、异常抑制和动态资源组合中的行为。这个材料的重点在运行时状态变化:manager expression 产生哪个对象,__enter__ 返回哪个绑定值,__exit__ 收到什么异常参数,返回值如何改变后续控制流。
Python 语言参考把 with 定义为一个围绕代码块执行进入与退出方法的 compound statement;标准库 contextlib 在这个协议之上提供 contextmanager、ExitStack 和 AsyncExitStack 等工具。正文会把这些文档事实收束成一套检查顺序:先定位获取点,再定位绑定值,再定位退出回调,最后判断异常传播策略。
Context management 的核心结论是:资源所有权应绑定到一个明确的作用域,退出动作应由协议统一触发,异常处理策略应集中在 __exit__ 或 __aexit__ 中表达。这个结论适用于文件、锁、事务、临时目录、网络连接、异步 session 和运行时数量才确定的资源组。
7.1 context manager protocol
Context manager protocol 是对象参与 with 语句的最小契约。同步 context manager 提供 __enter__ 和 __exit__ 两个 special method;__enter__ 负责进入作用域并返回绑定值,__exit__ 负责接收退出原因、执行清理动作,并用返回值表达异常传播策略。
这个协议解决的是资源生命周期分散的问题。没有 context manager 时,获取资源、使用资源、释放资源和异常处理经常散落在多个分支里。协议把这四件事收束到一个对象上,让调用方只需要写出作用域边界,让资源对象自己维护进入和退出规则。
下面的 TraceScope 是贯穿本章的观察材料。它把进入、退出和异常类型写入 events,因此可以直接看出 with 语句对协议方法的调用顺序。
class TraceScope:
def __init__(self, name, events, suppress=False):
self.name = name
self.events = events
self.suppress = suppress
def __enter__(self):
self.events.append(f"enter:{self.name}")
return f"handle:{self.name}"
def __exit__(self, exc_type, exc, tb):
label = exc_type.__name__ if exc_type is not None else "None"
self.events.append(f"exit:{self.name}:{label}")
return self.suppress and exc_type is ValueError
TraceScope("file", events) 这个表达式先产生 manager object。随后 with 语句通过协议进入对象,拿到 __enter__ 的返回值,再把返回值绑定给 as 后面的目标。离开 body 时,解释器把异常状态传给 __exit__。__exit__ 的返回值只在异常退出时参与判断;返回真值表示异常已经由 context manager 处理,返回假值表示异常继续沿调用栈传播。
这个协议的输入和输出需要分清。manager expression 的结果是 context manager 本身,as 目标接收的是 __enter__ 的返回值,__exit__ 接收的是退出时的异常三元组。很多阅读错误都来自把这三个对象混在一起:构造出的 manager、body 中使用的 handle、退出阶段处理异常的协议方法承担不同角色。
Python 对 __enter__ 和 __exit__ 使用 special method lookup。现阶段可以先记住一个工程结论:with 语句查找的是对象类型可见的协议能力,正文后续在对象系统和 descriptor 章节会展开 special method lookup 的完整路径。本章只需要把它作为 with 能否进入协议的判断依据。
7.2 with statement
with statement 的执行顺序可以按“求值 manager → 进入 → 绑定 → 执行 body → 退出”追踪。Python 语言参考的 with statement 说明了这个顺序,并给出单个 context manager 的等价展开。阅读源码或调试业务代码时,应先按这个顺序还原控制流,再判断异常路径。
下面这个片段展示普通完成路径。events 的内容来自协议方法调用,body 事件来自 with 代码块自身。
events = []
with TraceScope("file", events) as handle:
events.append(f"body:{handle}")
print(events)
结果对应的顺序是:先记录 enter:file,然后 body 看到 handle:file,最后记录 exit:file:None。None 表示 body 没有通过异常离开,解释器传给 __exit__ 的三个参数都是 None。
这个顺序可以用一个状态图表示。图中的异常分支只覆盖 __enter__ 成功返回后的 body 或 target assignment 异常;__enter__ 自身抛出异常时,同一个 manager 的 __exit__ 尚未注册为退出动作。
with A() as a, B() as b: 会按嵌套 with 处理。A().__enter__ 先执行,B().__enter__ 后执行;退出时顺序反过来,B().__exit__ 先收到退出状态,A().__exit__ 后收到状态。这个后进先出的顺序让内层资源先释放,外层资源最后收尾,和手写嵌套 try / finally 的资源栈一致。
return、break、continue 这类非异常退出也会触发 __exit__。这时 __exit__ 收到的异常参数仍然是三个 None,返回值对原本的控制流目标没有影响。只有异常退出时,__exit__ 的真值结果才会改变异常传播。
7.3 __enter__ / __exit__
__enter__ 的职责是把资源带入可用状态,并返回 body 应该使用的对象。这个返回值可以是 manager 自身,也可以是底层资源 handle,还可以是一个更窄的代理对象。判断 as target 的类型和能力时,应以 __enter__ 的返回值为准,再回头查看 manager expression 的类名。
下面的例子把 manager 与绑定值故意分开。NamedScope 自己维护清理逻辑,body 只拿到一个字符串 handle。
class NamedScope:
def __init__(self, events):
self.events = events
def __enter__(self):
self.events.append("enter")
return "runtime-handle"
def __exit__(self, exc_type, exc, tb):
self.events.append("exit")
return False
events = []
manager = NamedScope(events)
with manager as handle:
events.append(handle)
print(events)
这个片段中,manager 是拥有协议方法的对象,handle 是 __enter__ 返回的字符串。body 没有直接拿到 manager 对象,但退出阶段仍由 manager 的 __exit__ 负责。阅读第三方库时,open(...) as f、数据库连接池的 connection()、事务对象的 begin() 都可能存在类似分层:入口对象负责管理生命周期,body 中的绑定值负责执行业务操作。
__exit__ 的三个参数分别对应异常类型、异常对象和 traceback。普通完成时它们都是 None;异常完成时它们对应正在传播的异常状态。__exit__ 因此是资源对象观察失败路径的统一入口。事务可以在这里决定提交或回滚,锁可以在这里释放,临时目录可以在这里删除,监控对象可以在这里记录失败原因。
class Transaction:
def __init__(self, connection):
self.connection = connection
def __enter__(self):
self.connection.begin()
return self.connection
def __exit__(self, exc_type, exc, tb):
if exc_type is None:
self.connection.commit()
else:
self.connection.rollback()
self.connection.close()
return False
这个简化事务示例把提交、回滚和关闭放入同一个退出点。body 成功时提交,body 抛出异常时回滚,连接关闭动作在两个路径中都执行。return False 表示事务对象完成自己的清理后,异常继续交给调用方处理。
设计 __enter__ 和 __exit__ 时要保持所有权清晰。__enter__ 获取的资源应由 __exit__ 释放;如果资源来自外部调用方,context manager 应说明自己只借用资源,还是会在退出时关闭资源。所有权不清会导致重复释放、提前关闭或异常路径遗漏。
7.4 deterministic cleanup
Deterministic cleanup 指清理动作由明确的控制流边界触发。with 代码块结束就是这个边界;只要 __enter__ 成功返回,退出阶段就会调用 __exit__。这个性质让资源释放不依赖对象何时被垃圾回收,也不依赖调用方能否记住每条异常路径。
文件是最常见的例子。下面的写法把文件关闭绑定到 with suite 的结束点,读者可以从缩进直接看出文件可用范围。
def read_header(path):
with open(path, "r", encoding="utf-8") as file:
return file.readline()
这个函数在 return 离开 body 前会触发文件对象的退出逻辑。返回值已经生成,文件生命周期也已经收束。调用方拿到的是一行字符串,不持有打开的文件 handle。
CPython 的引用计数实现可能让某些对象在最后一个引用消失时很快析构,但这属于具体实现策略。跨 Python 实现、跨异步任务、跨异常路径分析资源生命周期时,应以 context manager 协议和作用域边界为准。with 给出的判断证据更稳定:进入点在哪里,退出点在哪里,异常状态如何传入退出方法。
Deterministic cleanup 还适用于锁、事务、临时目录和网络连接。锁需要保证释放顺序,事务需要根据异常状态提交或回滚,临时目录需要在使用结束后删除,网络连接需要关闭 socket 或归还连接池。它们的共同点是资源状态位于 Python 对象之外,清理动作需要在控制流离开作用域时发生。
from tempfile import TemporaryDirectory
def build_report(write_report):
with TemporaryDirectory() as workspace:
report_path = f"{workspace}/report.txt"
write_report(report_path)
with open(report_path, "r", encoding="utf-8") as file:
return file.read()
这里存在两个生命周期:临时目录覆盖整个报告生成过程,文件 handle 只覆盖读取过程。内层文件先关闭,外层临时目录后删除。缩进结构直接表达资源栈顺序,异常路径也沿同一栈顺序退出。
7.5 exception suppression
Exception suppression 是 __exit__ 返回真值时发生的控制流变化。异常对象已经传入 __exit__,资源对象决定这个异常是否已经被处理。返回真值后,解释器从 with 语句后的下一条语句继续执行;返回假值后,异常继续传播。
下面的 context manager 只处理 KeyError。它把异常类型判断集中在 __exit__,调用方的 body 保持直线写法。
class SuppressKeyError:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return exc_type is KeyError
data = {}
with SuppressKeyError():
data["missing"]
print("continued")
data["missing"] 抛出 KeyError,__exit__ 收到 KeyError 后返回 True,所以后续 print 会执行。若 body 抛出 ZeroDivisionError,__exit__ 返回 False,异常继续向外传播。这个设计让 suppression 的边界清晰:只处理预期异常类型,其他异常保持原有传播路径。
标准库 contextlib.suppress 提供了同类能力。工程上要把 suppression 当作显式错误策略,而非通用清理手段。清理动作适合放在所有退出路径中执行;错误抑制适合在异常类型、调用方预期和后续状态都清楚时使用。
异常抑制还会影响外层 context manager 接收到的状态。多项 with 按嵌套处理,内层 __exit__ 如果处理了异常,外层 __exit__ 会看到普通完成状态。这个现象在组合事务、锁和日志 scope 时很关键:内层对象改变异常传播,外层对象观察到的就是更新后的控制流状态。
__enter__ 抛出异常时,当前 manager 的 __exit__ 没有机会处理这个异常,因为进入动作尚未成功完成。需要给“部分进入成功”的资源提供清理能力时,应在 __enter__ 内部使用局部 try / finally,或使用 ExitStack 先注册已经获取成功的资源。
7.6 async with
async with 是异步 context manager 的语法入口。它使用 __aenter__ 和 __aexit__,并在进入和退出阶段执行 await。Python 语言参考的 async with statement 给出的等价展开显示,value = await aenter() 发生在 body 之前,await aexit(...) 发生在 body 离开之后。
异步 context manager 解决的是清理动作本身需要异步等待的问题。数据库连接、HTTP session、异步锁和消息队列 consumer 在关闭或归还时可能需要等待 I/O 完成。同步 __exit__ 无法表达这个等待点,__aexit__ 可以把清理纳入事件循环调度。
class AsyncSession:
def __init__(self, pool):
self.pool = pool
self.connection = None
async def __aenter__(self):
self.connection = await self.pool.acquire()
return self.connection
async def __aexit__(self, exc_type, exc, tb):
await self.pool.release(self.connection)
return False
使用时,async with 必须位于 coroutine function 内部。body 看到的是 __aenter__ 返回的连接对象,退出时连接归还动作通过 await self.pool.release(...) 完成。异常退出时,异常三元组同样传给 __aexit__,返回真值同样表示异常由异步 context manager 处理。
async def load_user(pool, user_id):
async with AsyncSession(pool) as connection:
return await connection.fetch_user(user_id)
异步清理需要把取消路径纳入设计。body 中的 coroutine 被取消时,取消会沿异常退出路径进入 __aexit__;__aexit__ 需要完成必要释放,并谨慎决定是否处理该异常。具体取消语义属于后续 asyncio 章节,本章只保留判断顺序:获取点在 __aenter__,使用范围在 async with body,释放点在 __aexit__,释放过程自身可以挂起。
contextlib 还提供 @asynccontextmanager,可以用异步生成器写出进入、yield 和退出逻辑。它适合把短异步资源包装成 context manager,但生成器在 yield 前后的异常处理仍要遵守 __aenter__ / __aexit__ 的语义边界。
7.7 ExitStack / AsyncExitStack
ExitStack 用于运行时才知道资源数量或资源组合的场景。普通嵌套 with 适合静态数量的资源;当资源列表来自配置、用户输入或前一步计算时,代码需要动态注册退出动作。标准库 contextlib.ExitStack 维护一个退出回调栈,并在关闭时按后进先出顺序调用。
下面的函数打开一组文件。任何一个文件打开失败时,已经成功进入的文件都会按栈顺序关闭;全部打开成功时,body 结束后也按同一规则关闭。
from contextlib import ExitStack
def read_first_lines(paths):
with ExitStack() as stack:
files = [
stack.enter_context(open(path, "r", encoding="utf-8"))
for path in paths
]
return [file.readline() for file in files]
stack.enter_context(cm) 会调用 cm.__enter__(),并把 cm.__exit__ 注册到栈中。后续关闭 stack 时,注册过的退出动作反向执行。这个模型等价于运行时构造了一组嵌套 with,因此内层资源先释放,外层资源后释放。
ExitStack 还可以注册普通回调。stack.callback(func, *args) 适合清理没有实现 context manager protocol 的资源;这类回调不会接收异常三元组,因此无法通过返回值抑制异常。需要接收异常状态的退出函数应使用 push 或 enter_context 注册,因为它们遵循 __exit__ 风格签名。
AsyncExitStack 是异步场景中的对应工具。它支持异步 context manager、同步 context manager 和异步清理回调的组合,并使用 aclose() 执行异步收束。典型场景是一次请求中动态创建多个异步连接、订阅或临时任务句柄。
from contextlib import AsyncExitStack
async def load_from_many(open_session, names):
async with AsyncExitStack() as stack:
sessions = [
await stack.enter_async_context(open_session(name))
for name in names
]
return [await session.fetch() for session in sessions]
这段代码把“已经成功进入的异步 session”交给 AsyncExitStack 管理。若后续某个 session 进入失败,之前成功进入的 session 仍会退出。若 body 抛出异常,栈中的 __aexit__ 会按反向顺序接收异常状态;某个内层退出动作处理或替换异常后,外层退出动作看到的是更新后的状态。
7.8 resource lifecycle management
Resource lifecycle management 要回答五个问题:资源在哪里获取,所有权属于谁,body 使用哪个对象,退出动作按什么顺序执行,异常路径由谁处理。with、async with、ExitStack 和 AsyncExitStack 都是在不同语法层面回答这五个问题。
分析一个 context manager 时,可以按下面的表格检查。这个顺序既适合阅读标准库,也适合评审业务代码中的资源封装。
| 检查点 | 需要确认的事实 | 常见工程后果 |
|---|---|---|
| 获取点 | 资源在构造函数、__enter__、__aenter__ 还是外部调用方创建 | 决定当前 manager 是否拥有释放责任 |
| 绑定值 | as target 接收 manager 自身、底层 handle 还是代理对象 | 决定 body 可以调用哪些方法 |
| 退出点 | __exit__、__aexit__、ExitStack 回调还是外部 close | 决定普通路径和异常路径是否统一收束 |
| 退出顺序 | 单个 scope、嵌套 scope、动态栈或异步栈 | 决定内外层资源依赖是否安全 |
| 异常策略 | 返回假值传播、返回真值处理、转换异常或记录后传播 | 决定调用方能观察到什么错误状态 |
| 重入能力 | manager 能否多次进入,能否嵌套进入,是否一次性对象 | 决定对象能否复用或作为 decorator 使用 |
| 重复释放保护 | 多次 close、部分进入失败、退出阶段再抛异常如何处理 | 决定失败路径是否保持资源状态一致 |
这个检查顺序可以迁移到不同资源。文件对象通常拥有自身关闭责任,as target 返回文件 handle,退出策略以关闭为主。事务对象通常拥有提交或回滚责任,as target 可能返回 connection,异常策略直接影响数据状态。异步 session 的退出动作需要 await,取消路径和连接池状态要一起检查。
一个可靠的 context manager 应把资源边界写在协议方法中,把业务逻辑留在 body 中,把异常策略写成明确判断。调用方通过缩进读作用域,通过 as 读可用对象,通过 __exit__ 或 __aexit__ 的返回值读错误策略。这样分析时不需要猜测对象何时析构,也不需要把每条分支都手工展开成清理代码。
本章建立的主模型是:context manager 把资源生命周期转成对象协议,把控制流退出转成异常三元组,把动态资源组转成退出栈。掌握这个模型后,阅读 open、TemporaryDirectory、数据库事务、异步 session 和 ExitStack 代码时,都可以从同一条路径追踪进入、绑定、使用、退出和异常传播。
最小自检任务
阅读下面的代码,判断 events 的最终内容,并说明哪个 __exit__ 收到 ValueError,外层 except ValueError 是否会执行。
events = []
class Scope:
def __init__(self, name, suppress=False):
self.name = name
self.suppress = suppress
def __enter__(self):
events.append(f"enter:{self.name}")
return self.name.upper()
def __exit__(self, exc_type, exc, tb):
label = exc_type.__name__ if exc_type is not None else "None"
events.append(f"exit:{self.name}:{label}")
return self.suppress and exc_type is ValueError
try:
with Scope("outer") as outer, Scope("inner", suppress=True) as inner:
events.append(f"body:{outer}:{inner}")
raise ValueError("bad")
events.append("after-with")
except ValueError:
events.append("caught")
print(events)
答案要点
with Scope("outer") as outer, Scope("inner", suppress=True) as inner: 等价于两个嵌套 with。进入顺序是 outer 先进入,inner 后进入;退出顺序是 inner 先退出,outer 后退出。
body 抛出 ValueError 后,inner 的 __exit__ 先收到 ValueError,并返回 True。这个返回值表示 inner 已经处理该异常,所以异常状态在内层 with 结束时被清除。outer 随后按普通完成路径退出,因此 outer 的 __exit__ 收到的异常类型是 None。
最终 events 为:
[
"enter:outer",
"enter:inner",
"body:OUTER:INNER",
"exit:inner:ValueError",
"exit:outer:None",
"after-with",
]
外层 except ValueError 不会执行,因为 ValueError 已经由 inner 的 __exit__ 处理。若 inner 返回 False,outer 会继续收到 ValueError;若 outer 也返回 False,外层 except ValueError 才会执行。
本章知识点总结
- 协议入口:context manager 通过
__enter__和__exit__把资源进入、绑定、退出和异常处理收束到对象协议中。 - 绑定对象:
as target接收的是__enter__的返回值,manager expression 的结果负责提供协议方法。 - 退出参数:普通完成时
__exit__收到三个None,异常完成时收到异常类型、异常对象和 traceback。 - 执行顺序:
with按求值 manager、进入、绑定、执行 body、退出的顺序推进。 - 嵌套顺序:多项
with等价于嵌套with,进入按书写顺序,退出按后进先出顺序。 - 确定清理:deterministic cleanup 把释放动作绑定到代码块退出点,使资源生命周期可由缩进范围判断。
- 异常处理:
__exit__返回真值会处理异常,返回假值会让异常继续沿调用栈传播。 - 进入失败:
__enter__尚未成功返回时,同一个 manager 的__exit__还没有进入退出路径。 - 异步协议:
async with使用__aenter__和__aexit__,进入与退出阶段都可以执行await。 - 动态资源:
ExitStack把运行时数量才确定的退出动作注册到栈中,并按后进先出顺序收束。 - 异步栈:
AsyncExitStack负责组合异步 context manager 和异步清理回调,关闭阶段使用异步收束。 - 生命周期判断:分析资源封装时应依次确认获取点、所有权、绑定值、退出顺序、异常策略和重复释放保护。