← 返回题目列表

contextlib 提供了哪些工具?@contextmanager 和 ExitStack 怎么用?

中等 第 17 / 27 题 更新于 2026/07/31
contextlibcontextmanagerExitStacksuppress

简化版

contextlib 是标准库里「造上下文管理器 + 组合上下文管理器」的工具箱,省得你为每个 with 都手写一个带 __enter__/__exit__ 的类。最常用的六个:@contextmanager——把一个「yield 一次的生成器函数」变成上下文管理器:yield 之前是 __enter__yield 出去的值给 asyield 之后是 __exit__suppress(异常类)——安静吞掉指定异常,替代 try/except X: passclosing(obj)——给只有 close()、没有 __exit__ 的老对象套上 withExitStack()——动态数量的上下文管理器统一管理(要打开 N 个文件、N 在运行时才知道),退出时按后进先出逐个关闭;nullcontext(x)——「什么都不做」的占位上下文,解决「有时要 with 有时不要」的分支重复;redirect_stdout/redirect_stderr——临时重定向标准输出。另外 @contextmanager 写的东西还能当装饰器用ContextDecorator),异步版是 @asynccontextmanager + AsyncExitStack最大的坑@contextmanager 里如果不用 try/finally 包住 yield,一旦 with 体内抛异常,yield 之后的清理代码根本不会执行(资源泄漏)。核心记忆:@contextmanager 用生成器写 with、清理代码必须放 finally、动态数量用 ExitStack

详细版

工具速查表

工具解决什么问题等价的手写版
@contextmanager不想为一个 with 写整个类__enter__/__exit__ 的类
suppress(FileNotFoundError)吞掉预期内的异常try: ... except X: pass
closing(obj)对象只有 close() 没有 __exit__try: ... finally: obj.close()
ExitStack()数量不定 / 条件性的资源手写嵌套 with(数量固定才行)
nullcontext()「有时要 with,有时不要」if/else 两份重复代码
redirect_stdout(f)临时把 print 重定向手动存取 sys.stdout
@asynccontextmanager异步版的 @contextmanager__aenter__/__aexit__
AsyncExitStack()异步版的 ExitStack——
from contextlib import (contextmanager, suppress, closing, ExitStack,
                        nullcontext, redirect_stdout)
import io, time

# ① @contextmanager:yield 前=进入,yield 后=退出
@contextmanager
def timer(label):
    start = time.perf_counter()
    try:                                  # ★ 必须 try/finally
        yield start                       # 这个值给 as
    finally:
        print(f"{label} 耗时 {time.perf_counter()-start:.3f}s")   # 异常时也会打印

with timer("查询") as t0:
    sum(range(10**6))

# ② suppress:吞掉预期异常
import os
with suppress(FileNotFoundError):
    os.remove("可能不存在.txt")            # 不存在也不炸

# ③ ExitStack:数量在运行时才知道
paths = ["a.txt", "b.txt", "c.txt"]        # 数量不定
with ExitStack() as stack:
    files = [stack.enter_context(open(p, "w")) for p in paths]
    for f in files:
        f.write("x")
# 退出时按★后进先出★依次关闭 c、b、a

# ④ nullcontext:消除 if/else 重复
def process(path, lock=None):
    with (lock or nullcontext()):          # 没有锁就用"空上下文"
        return open(path).read()

# ⑤ redirect_stdout:抓住第三方库的 print
buf = io.StringIO()
with redirect_stdout(buf):
    print("这句被抓走了")
print("捕获到:", buf.getvalue().strip())   # 捕获到: 这句被抓走了

# ⑥ @contextmanager 的对象还能当装饰器(ContextDecorator)
@timer("批处理")
def batch():                               # 等价于函数体外面套一层 with timer(...)
    sum(range(10**5))
batch()

# ⑦ closing:老式对象只有 close()
class OldConn:
    def close(self): print("closed")
with closing(OldConn()) as c:              # 给它补上 __exit__
    pass

⚠️ @contextmanager 的头号坑:清理代码必须放在 finally 里。 @contextmanager 的实现方式是:__enter__ 时把生成器推进到 yield(拿到 yield 出来的值),__exit__ 时——如果 with 体正常结束就调 next() 让生成器跑完;如果 with抛了异常,则把这个异常通过 gen.throw(exc) 扔回 yield 那一行。也就是说,异常是在 yield 处「爆炸」的:如果你写成 yield; 清理()(没有 try),异常一炸,清理() 这行就被跳过了,连接不会归还、锁不会释放、文件不会关闭——而这恰恰是最需要清理的时候。所以模板永远是 try: yield x finally: 清理()。相关地:如果你想「吞掉」with 体里的异常,要在 exceptreturn(不要 yield 第二次,那会抛 RuntimeError: generator didn't stop);反过来,__exit__ 返回真值会吞异常,而 @contextmanager 里默认不吞——异常会继续往外传。

完整版教学

一、@contextmanager:用生成器代替一个类

手写类的版本(8 行 + 两个方法):
  class Timer:
      def __init__(self, label): self.label = label
      def __enter__(self):
          self.t0 = time.perf_counter()
          return self.t0                     # ← 这个值给 as
      def __exit__(self, exc_type, exc, tb):
          print(self.label, time.perf_counter() - self.t0)
          return False                       # ← False = 不吞异常

@contextmanager 版本(等价,但只有一个函数):
  @contextmanager
  def timer(label):
      t0 = time.perf_counter()
      try:
          yield t0                           # ← 这一行就是 __enter__ 的 return
      finally:
          print(label, time.perf_counter() - t0)   # ← 这就是 __exit__

对应关系(背下来):
  yield 之前的代码  ←→  __enter__ 的函数体
  yield 出去的值    ←→  __enter__ 的返回值(给 as)
  yield 之后的代码  ←→  __exit__ 的函数体
  try/finally      ←→  保证异常时 __exit__ 也执行

执行时序(with 体内没有异常):
  进入 with → 生成器跑到 yield 暂停 → 执行 with 体 → 恢复生成器跑完 → 退出

执行时序(with 体内抛了 ValueError):
  进入 with → 跑到 yield 暂停 → with 体抛 ValueError
  → contextlib 调用 gen.throw(ValueError) → ★异常从 yield 那一行抛出★
  → finally 执行清理 → 异常继续向外传播(默认不吞)

必须"只 yield 一次":
  yield 两次 → RuntimeError: generator didn't stop
  一次都不 yield → RuntimeError: generator didn't yield

@contextmanager 的本质是把一个「只 yield 一次」的生成器包装成上下文管理器ContextManager.__enter__ 内部做的是 next(gen)——让生成器跑到 yield 处暂停,并把 yield 出来的值作为 as 的绑定;__exit__ 内部做的是「让生成器继续跑完」。理解这一点,所有行为就都能推出来了:为什么必须只 yield 一次(yield 两次说明「退出」之后还有一段身体,语义上说不通,直接报 RuntimeError)、为什么 with 体的异常会在 yield 那一行爆炸(contextlib 是用 gen.throw() 把异常送回生成器的暂停点)、为什么清理必须放 finally(否则异常一炸就跳过了)。写法上它比手写类短一半,且「获取资源—使用—释放资源」三步在一个函数里从上往下读,比拆在两个方法里更直观。

二、清理代码为什么必须放在 finally

✗ 错误写法(漏 try/finally):
  @contextmanager
  def get_conn():
      conn = pool.acquire()
      yield conn
      pool.release(conn)          # ← with 体抛异常时,这行永远不执行!

  with get_conn() as c:
      raise ValueError("业务出错")
  → 异常从 yield 那行抛出 → 直接向外传播 → release 被跳过
  → 连接泄漏;跑 1000 次就把连接池耗干(而且是"越出错泄漏越快")

✓ 正确写法:
  @contextmanager
  def get_conn():
      conn = pool.acquire()
      try:
          yield conn
      finally:
          pool.release(conn)      # ← 正常/异常/return/break 都会执行

想在出错时做额外处理(比如回滚):
  @contextmanager
  def transaction(conn):
      tx = conn.begin()
      try:
          yield conn
      except Exception:
          tx.rollback()
          raise                   # ★ 记得 raise,否则异常被静默吞掉
      else:
          tx.commit()             # 只有没异常才提交

想"吞掉"某类异常(少用):
  try:
      yield
  except TimeoutError:
      logger.warning("超时,已忽略")
      # 不 raise = 吞掉;★千万别在这里再 yield 一次(RuntimeError)★

三种收尾语义对照:
  finally  → 无论如何都执行(释放资源)
  except   → 只有出异常才执行(回滚、告警)
  else     → 只有没出异常才执行(提交)

这是 @contextmanager 唯一的高频事故点,值得单独记:上下文管理器存在的意义就是「异常时也保证清理」,而漏掉 try/finally 恰好让它在异常时不清理——功能退化成了一个普通函数调用,还给人一种「我已经用 with 管好了」的错觉,泄漏会拖很久才被发现(表现是连接池慢慢耗干、文件句柄数缓慢上涨)。模板要记牢三段式的分工:finally 放「无论如何都要做」的释放;except 放「出错才做」的回滚并记得 raise 重新抛出(不 raise 就等于把业务异常静默吞了,比泄漏更可怕);else 放「没出错才做」的提交。这套结构就是数据库事务上下文管理器的标准写法。

三、suppress / closing / nullcontext:三个小工具

① suppress(*异常类):安静吞掉指定异常
   传统写法                          suppress 写法
   try:                              with suppress(FileNotFoundError):
       os.remove(p)                      os.remove(p)
   except FileNotFoundError:
       pass
   ★ 注意语义:异常一旦发生,with 体内★剩下的语句会被跳过★
     with suppress(ValueError):
         a = int("x")     # 抛 ValueError
         print("这句不会执行")   ← 直接跳到 with 之后
   ★ 只吞你写明的那类,别 suppress(Exception) 当万能创可贴

② closing(obj):给只有 close() 的对象补上 with
   from urllib.request import urlopen
   with closing(urlopen(url)) as page:      # 老式对象没有 __exit__
       data = page.read()
   等价于 try: ... finally: obj.close()
   (注意:现代的 open()、socket、requests.Session 自带 __exit__,不需要 closing)

③ nullcontext(值=None):什么都不做的占位上下文
   问题:有时需要加锁、有时不需要 → 写成 if/else 会重复整段业务代码
   ✗ if lock:
         with lock: 干活()      ← 业务代码写了两遍
      else:
         干活()
   ✓ with (lock or nullcontext()):
         干活()                 ← 只写一遍

   还能带值(常用于"文件路径或已打开的文件对象都接受"的 API):
   ✓ cm = open(f) if isinstance(f, str) else nullcontext(f)
     with cm as fp:            # 传路径就负责开关;传文件对象就原样用、不替调用方关闭
         fp.read()

这三个小工具都是「消除样板代码」的:suppress 把四行 try/except/pass 压成一行,但要注意它的语义是跳出整个 with(异常发生点之后的语句不再执行),并且只该 suppress 你确实预期会发生的那一类异常——suppress(Exception) 会把真正的 bug 也一起吞掉。closing 是给「只有 close() 方法、没实现 __exit__」的老式对象补协议用的,现代库大多自带 __exit__,用不上它。nullcontext 看起来没用,实际非常解痒:它专治「条件性的 with」——不用它就得把业务代码在 if 和 else 里各写一遍;它还能 nullcontext(已打开的文件),让一个 API 同时接受「路径」和「已打开的对象」,且不会去关闭调用方传进来的对象(谁打开谁负责关,这是很重要的资源礼仪)。

四、ExitStack:数量不定的资源怎么管

问题场景:要合并 N 个文件,N 在运行时才知道
  ✗ 固定嵌套:with open(a) as f1, open(b) as f2:  ← 只能写死数量
  ✗ 手写 try/finally:得自己记住已经打开了哪几个、逆序关闭、还要处理"开到一半失败"

ExitStack = 一个"待关闭资源的栈",退出时后进先出(LIFO)逐个调用它们的 __exit__
  with ExitStack() as stack:
      files = [stack.enter_context(open(p)) for p in paths]   # 开几个都行
      合并(files)
  # 退出:按 逆序 关闭(最后打开的最先关)

为什么必须逆序(LIFO):资源常有依赖关系
  打开顺序: 连接 → 事务 → 游标
  关闭顺序: 游标 → 事务 → 连接        ← 反着来才安全
  (先关连接再关游标 = 对已死的连接操作,报错或泄漏)

★ 部分失败也安全:
  开到第 7 个文件时磁盘满了抛异常 → 前 6 个已入栈的照样被正确关闭
  (手写循环 + try/finally 想做到这点要写十几行)

三个常用能力:
  stack.enter_context(cm)       把一个上下文管理器压栈,返回它的 __enter__ 值
  stack.callback(func, *args)   压入一个"退出时要调用的普通函数"(不必是上下文管理器)
  stack.pop_all()               ★把所有清理动作转移出去★ → 用于"成功就不清理"

pop_all 的经典用法(要么全成、要么全回滚):
  with ExitStack() as stack:
      conns = [stack.enter_context(connect(h)) for h in hosts]
      握手校验(conns)                 # 任何一步失败 → 已建立的连接全部关闭
      stack.pop_all()                 # 全部成功 → 撤销清理,把连接交出去
      return conns

条件性入栈也很自然:
  with ExitStack() as stack:
      if need_lock:  stack.enter_context(lock)
      if need_trace: stack.enter_context(profiler())
      干活()

ExitStack 解决的是「with a, b, c: 这种写法只能应付编译期就知道数量的资源」这个硬限制。它内部维护一个栈,enter_context 把上下文管理器压栈并立刻执行其 __enter__,退出时逆序(LIFO)弹栈调用每个 __exit__——逆序不是随意的设计,而是因为资源常有依赖关系(连接→事务→游标必须反过来关)。它另一个被低估的价值是部分失败的安全性:打开第 7 个资源时炸了,前 6 个照样被正确清理,这段逻辑手写要十几行还容易漏。stack.callback(fn, args) 让你把任意「退出时要做的事」(删临时目录、发通知)也纳入同一套管理。而 pop_all() 是实现「要么全部成功、要么全部回滚」的钥匙:先把资源都挂在栈上,中途任何失败都自动清理,全部成功后调 pop_all() 撤销清理责任,把资源完好地交给调用方。

五、当装饰器用:ContextDecorator

@contextmanager 造出来的对象,天生也能当装饰器(继承自 ContextDecorator)

  @contextmanager
  def timing(label):
      t0 = time.perf_counter()
      try:
          yield
      finally:
          print(label, time.perf_counter() - t0)

  用法一(with):      with timing("A"): 干活()
  用法二(装饰器):     @timing("B")
                        def f(): 干活()        ← 等价于函数体外面套一层 with

★ 关键陷阱:装饰器用法下,"每次调用函数"都会重新执行一遍生成器
  @timing("B")
  def f(): ...
  f(); f()        # 计时两次,各自独立 ✓(因为装饰器每次调用都新建一个上下文实例)

  但如果你手动这样写就废了:
  cm = timing("B")     # 只造了一个实例
  with cm: ...
  with cm: ...         # ✗ RuntimeError: generator didn't yield(生成器已耗尽,不可重入)
  → @contextmanager 造的上下文管理器是"一次性"的;
    需要重复/嵌套使用,就每次现调用一次工厂函数(timing("B")),
    或者改写成手写类(类实例可以设计成可重入的,如 threading.RLock)

自己写类想要这个能力:继承 contextlib.ContextDecorator
  class Timing(ContextDecorator):
      def __enter__(self): ...
      def __exit__(self, *exc): ...
  → Timing() 既能 with 也能当 @Timing() 用

什么时候用装饰器形态:整个函数体都要被包住(打点、计时、临时切配置)
什么时候用 with 形态:只包函数里的一小段

@contextmanager 产出的对象同时也是装饰器,这来自 ContextDecorator 基类——@timing("B") 等价于「把整个函数体塞进 with timing("B"):」。这个双形态很实用:整段函数都要计时/打点就用装饰器形态,只包其中几行就用 with 形态,一份代码两种用法。但要牢记一个陷阱:@contextmanager 生成的上下文管理器实例是一次性的(底层是个生成器,跑完就耗尽了),把它存成变量反复 with 会抛 RuntimeError: generator didn't yield。装饰器形态之所以能反复调用不出事,是因为装饰器每次调用函数时都会重新调用一次工厂函数造新实例。需要一个「可重入、可复用」的上下文管理器时(类似 threading.RLock),就得老老实实手写类并继承 ContextDecorator

六、异步版本与选型总结

异步世界的对应物:
  @contextmanager   →  @asynccontextmanager   (用 async def + yield)
  ExitStack         →  AsyncExitStack         (enter_async_context / aclose)
  closing           →  aclosing               (3.10+,await obj.aclose())
  nullcontext       →  nullcontext 也支持 async with(3.10+)

  @asynccontextmanager
  async def get_session():
      s = aiohttp.ClientSession()
      try:
          yield s
      finally:
          await s.close()        # ← 异步清理必须 await

  async with get_session() as s: ...

  ★ AsyncExitStack 可以混装同步和异步资源:
    async with AsyncExitStack() as st:
        conn = await st.enter_async_context(async_conn())   # 异步的
        f    = st.enter_context(open("log.txt"))            # 同步的也能进

选型决策树:
  只用一次、逻辑简单                  → @contextmanager(最省事)
  需要重入 / 复用同一个实例 / 有状态   → 手写类(__enter__/__exit__)
  数量不定 / 条件性 / 要部分失败安全   → ExitStack
  只是想吞个异常                      → suppress
  对象只有 close()                    → closing
  "有时要 with 有时不要"              → nullcontext
  异步资源                            → @asynccontextmanager / AsyncExitStack

统一心法:把"成对出现的操作"(开/关、加锁/解锁、进/出、改配置/还原)
交给上下文管理器,就不会因为中途 return、break、异常而漏掉后半截。

异步场景下全套工具都有对应版本,规则也一模一样,只是清理动作前要 awaitfinally: await s.close()),而且必须用 async withAsyncExitStack 尤其顺手:它能同时容纳同步和异步资源enter_contextenter_async_context 混用),在「一个异步任务里既要开数据库连接又要写本地文件」时避免了两套嵌套。最后把选型收敛成一句心法:凡是「成对出现」的操作——开/关、加锁/解锁、改配置/还原现场、进/出目录——都交给上下文管理器,这样无论中途 returnbreak 还是抛异常,后半截都不会漏;至于用哪个工具,按「逻辑简单用 @contextmanager、要复用用类、数量不定用 ExitStack」三档来选就够了。

记忆钩子:「contextlib 就记四件事:①@contextmanager 用『只 yield 一次的生成器』写 with——yield 前=__enter__、yield 的值给 as、yield 后=__exit__;②清理代码必须裹在 try/finally 里,因为 with 体的异常是被 gen.throw() 送回 yield 那一行『就地爆炸』的,没有 finally 就直接跳过清理(越出错泄漏越快),事务模板是 try: yield / except: rollback; raise / else: commit;③数量不定、条件性、要『部分失败也安全』的资源用 ExitStack(LIFO 逆序关闭,pop_all() 实现全成才交付);④小工具三件套:suppress 吞预期异常、closing 给只有 close() 的老对象补协议、nullcontext 消除『有时要 with 有时不要』的分支重复。异步全套加 a:@asynccontextmanager/AsyncExitStack/aclosing。还有一个隐藏坑:@contextmanager 造的实例是一次性的,存成变量反复 with 会 RuntimeError: generator didn't yield。」

七、常见误区与追问

  • 误区:@contextmanageryield 后面的代码一定会执行,不用写 try/finally 恰恰相反——with 体内抛出的异常,contextlib 是通过 gen.throw(exc) 送回 yield 那一行重新抛出的,所以异常会从 yield 处向外炸,yield 之后的语句被整个跳过。写成 conn = acquire(); yield conn; release(conn) 时,正常路径看起来一切正常,一旦业务代码抛异常,release 就不执行了——而这正是最需要释放的时刻,结果是「越出错泄漏越快」的连接池耗尽,且往往拖到线上高峰才暴露。永远写成 try: yield x finally: 清理()
  • 误区:@contextmanager 造出来的对象可以存成变量反复使用。 它底层是一个生成器,跑完就耗尽了,属于一次性对象:cm = timer("x") 之后第一次 with cm: 正常,第二次会抛 RuntimeError: generator didn't yield;嵌套使用同一个实例同样会炸。要重复使用就每次重新调用工厂函数(with timer("x"): 每次都新建一个),要「可重入」(像 RLock 那样同一线程可以嵌套获取)就必须手写类。装饰器形态 @timer("x") 之所以能反复调用函数而不出事,正是因为它每次调用都重新创建了新实例。
  • 误区:suppress(Exception) 是个方便的兜底写法。 这是把 except Exception: pass 换了个漂亮外衣,同样会吞掉真正的 bugAttributeErrorKeyError、拼错的方法名、下游服务的严重错误全被静默忽略,程序带着错误的中间状态继续跑,排查时连一行日志都没有。suppress 只该用于「你明确预期会发生、且发生了确实什么都不用做」的具体异常类,最典型的就是 suppress(FileNotFoundError) 删除可能不存在的文件。另外要记住它的控制流语义:异常一旦发生,with 体内后续语句全部跳过,直接跳到 with 之后——别在 suppress 块里放多条互相依赖的语句。
  • 误区:ExitStack 只是「少写几层嵌套 with」的语法糖。 它真正不可替代的地方有三个:① 数量在运行时才确定with a, b, c: 的写法必须在代码里写死数量);② 条件性资源if need_lock: stack.enter_context(lock),用嵌套 with 得写成分支重复的业务代码);③ 部分失败的安全清理——打开第 7 个资源时抛异常,前 6 个已入栈的会被正确逆序关闭,手写循环要实现这点得十几行还容易漏。再加上 pop_all() 能实现「全部成功才把资源交出去、中途任何失败自动全回滚」,这些都不是语法糖能概括的。
  • 误区:with 里的异常被 __exit__ 处理后就不会外传。 默认是会外传的:__exit__ 只有返回真值时才表示「异常已被我吞掉」,返回 None/False(大多数实现的默认)异常继续向外传播。@contextmanager 的对应规则是:在生成器里 except SomeError: 处理raise 就等于吞掉,raise 或不捕获就继续外传。实际写事务上下文时最常见的 bug 就是 except Exception: tx.rollback()忘了 raise——数据回滚了,但调用方以为一切正常,错误被静默咽下去。
  • 追问:@contextmanager 和手写 __enter__/__exit__ 的类,各自适合什么场景?@contextmanager 的场景:逻辑简单、一次性使用、「获取—使用—释放」三步想写在一个函数里从上往下读(计时、临时改配置、开个事务),代码量约为类写法的一半。必须手写类的场景:① 需要可重入或可复用(同一个实例被多次/嵌套 with,生成器版会 RuntimeError);② 上下文管理器本身要携带状态或提供额外方法(比如 with lock: 之外还想调 lock.locked());③ 需要继承体系或被其他类混入;④ 对 __exit__ 的异常处理要做复杂判断(拿到 exc_type/exc/tb 三个参数分类处理,比在生成器里写多个 except 更清晰)。两者可以互补:类写法配合继承 ContextDecorator 同样能获得「既能 with 又能当装饰器」的能力。
  • 追问:ExitStack 为什么按后进先出(LIFO)顺序退出? 因为资源之间通常存在依赖关系,而依赖的建立顺序天然是从底层到上层:先连数据库、再开事务、再建游标;先创建临时目录、再在里面建文件。释放时必须反过来(游标→事务→连接、文件→目录),否则就会「对已经失效的底层资源做操作」——关掉连接后再关游标会报错,删掉目录后再关文件会失败。这与嵌套 with a: with b: 的退出顺序(先退 b 再退 a)完全一致,ExitStack 只是把这个顺序从语法嵌套搬到了运行时的栈里。同样的道理,stack.callback() 注册的普通清理函数也遵守 LIFO。
  • 追问:nullcontext 除了「条件性加锁」还有什么典型用途? 最典型的是写一个「既接受文件路径、也接受已打开的文件对象」的 API:如果传的是路径,就 open() 并在用完后关闭;如果传的是调用方已经打开的对象,就直接用、且绝不能替调用方关闭它(否则调用方后续再用就是「操作已关闭的文件」)。写法是 cm = open(f) if isinstance(f, str) else nullcontext(f),然后统一 with cm as fp:——nullcontext(f)__enter__ 直接返回 f__exit__ 什么都不做,完美表达了「谁打开谁负责关」的资源礼仪。类似的还有「调试模式下才启用 profiler、否则空转」、「测试里用 pytest.raises(X),正常路径用 nullcontext()」这样把两条分支统一成一行的写法。

八、加强记忆

contextlib 是「造 with + 组合 with」的工具箱。核心是 @contextmanager:把一个「只 yield 一次的生成器」变成上下文管理器——yield 之前 = __enter__、yield 出去的值给 as、yield 之后 = __exit__,一个函数顶一个类。它的唯一高频事故是漏写 try/finallywith 体的异常是被 gen.throw() 送回 yield 那一行就地爆炸的,没有 finally 就直接跳过清理,形成「越出错泄漏越快」的连接池耗尽;事务模板固定为 try: yield / except: rollback(); raise / else: commit()except别忘了 raise,否则业务异常被静默吞掉)。ExitStack 负责嵌套 with 干不了的三件事:数量运行时才知道、条件性资源、部分失败也能安全清理,退出按 LIFO 逆序(因为连接→事务→游标必须反着关),pop_all() 实现「全部成功才把资源交出去」。三个小工具:suppress 吞掉明确预期的具体异常(别写 suppress(Exception),且注意异常后 with 体剩余语句会被跳过)、closing 给只有 close() 的老对象补协议、nullcontext 消除「有时要 with 有时不要」的分支重复(还能让 API 同时接受路径和已打开对象、且不越权关闭别人的资源)。异步版全套加 a:@asynccontextmanagerAsyncExitStack(可混装同步异步资源)、aclosing。最后两个易忘点:@contextmanager 造的实例是一次性的(存成变量反复 withRuntimeError: generator didn't yield,要重入就手写类),以及 __exit__ 返回真值才吞异常、默认继续外传。