Skip to main content

Chapter 7: Context Management Semantics

with 语句把资源生命周期写进 Python 运行时协议:进入代码块前获取资源,离开代码块时释放资源,并把异常状态交给资源对象判断。读完本章后,读者应能追踪一个 withasync with 片段的进入顺序、绑定对象、异常传播、退出顺序和资源释放边界。

本章的贯穿材料是一个短资源作用域。它记录进入、退出和异常信息,用来观察 context manager 在普通完成、异常完成、异常抑制和动态资源组合中的行为。这个材料的重点在运行时状态变化:manager expression 产生哪个对象,__enter__ 返回哪个绑定值,__exit__ 收到什么异常参数,返回值如何改变后续控制流。

Python 语言参考把 with 定义为一个围绕代码块执行进入与退出方法的 compound statement;标准库 contextlib 在这个协议之上提供 contextmanagerExitStackAsyncExitStack 等工具。正文会把这些文档事实收束成一套检查顺序:先定位获取点,再定位绑定值,再定位退出回调,最后判断异常传播策略。

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:NoneNone 表示 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 的资源栈一致。

returnbreakcontinue 这类非异常退出也会触发 __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 的资源;这类回调不会接收异常三元组,因此无法通过返回值抑制异常。需要接收异常状态的退出函数应使用 pushenter_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 使用哪个对象,退出动作按什么顺序执行,异常路径由谁处理。withasync withExitStackAsyncExitStack 都是在不同语法层面回答这五个问题。

分析一个 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 把资源生命周期转成对象协议,把控制流退出转成异常三元组,把动态资源组转成退出栈。掌握这个模型后,阅读 openTemporaryDirectory、数据库事务、异步 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 和异步清理回调,关闭阶段使用异步收束。
  • 生命周期判断:分析资源封装时应依次确认获取点、所有权、绑定值、退出顺序、异常策略和重复释放保护。