AIGC标识 Python 的 with 到底在管什么?拆开上下文管理器:__exit__ 返回值决定生死,异常从 yield 灌进去

结论先行:with 不是语法糖版的 try/finally——它把「资源生命周期」拆成了两次函数调用:enter 拿资源、exit 还资源,而 exit 的返回值就是异常的生杀大权:返回 True,异常被当场吞掉;返回 False 或 None,异常原样穿透。更反直觉的是 @contextmanager 那一套:with 块里抛出的异常不是「被 except 抓到」,而是被 throw 进生成器、从 yield 那个点灌进去的。实测这套协议本身只要 353 纳秒,但一个没关掉的文件句柄能拖垮整个服务。这篇用 6 组实验把协议拆到底。


1. 两次调用:with 的全部秘密

把 with 语句编译成字节码,结构极其简单:

  1  BEFORE_WITH      # 调用 __enter__,结果压栈(就是 as 绑定的值)
  2  ...              # with 块体
  3  LOAD_CONST None
  4  PUSH_EXC_INFO    # 记录:如果块体炸了,异常信息在这里
  5  WITH_EXCEPT_START# 调 __exit__(type, val, tb),看返回值决定吞不吞
  6  POP_EXCEPT      # 块体正常结束也走这里:__exit__(None, None, None)

正常路径:__enter__() → 块体 → __exit__(None, None, None)。

异常路径:块体炸了 → __exit__(异常类型, 异常值, traceback) → 返回真值就地吞掉,返回假值原样抛出。

实测 tracer 类的调用序列完全吻合:

__enter__ called
body running
__exit__ called, exc = None

2. exit 的返回值 = 异常的生杀大权

这是整个协议里唯一需要记住的规则。三个实测场景:

__exit__ 返回 块体抛 ValueError 实测结果
True 吞掉 suppress __exit__ exc = ValueError → 后续代码继续执行
False 穿透 reraise __exit__ exc = KeyError → 外层 try 抓到 KeyError
None(默认) 穿透 同 False,异常原样抛出

这就是 contextlib.suppress(FileNotFoundError) 的全部实现原理——它的 __exit__ 就是一句 return issubclass(exc_type, self._exceptions)。一个返回值,把一个 try/except/pass 三件套压缩成了 with 一行。

3. @contextmanager:yield 是 enter/exit 的分界线

@contextlib.contextmanager 把一个生成器函数变成上下文管理器,切面就一刀:

@contextlib.contextmanager
def res(tag):
    print('enter: acquire', tag)   # yield 之前的代码 = __enter__
    try:
        yield tag                  # ← as 绑定的值;块体在这里跑
    finally:
        print('exit: release', tag)  # yield 之后的代码 = __exit__

实测输出:enter: acquire DB → using DB → exit: release DB。****yield 之前是拿资源,yield 之后是还资源,with 块体整个被塞进 yield 的暂停窗口里。

4. 异常不是被 except 抓的,是被 throw 进去的

这是 @contextmanager 最容易被误解的地方。看这组实测:

@contextlib.contextmanager
def res2():
    try:
        yield
    except ValueError:
        print('gen caught ValueError (via throw)')   # ← 这一行会打印
    finally:
        print('gen finally')
with res2():
    raise ValueError('x')

输出证实:gen caught ValueError (via throw) 和 gen finally 都打印了。

****块体里的 ValueError 不是「冒泡到 except」——它是被 gen.throw(ValueError) 从 yield 那个点直接灌进生成器的。所以写在 yield 后面的 except 能抓到 with 块体的异常,写在 yield 前面的代码(enter 阶段)想抓也抓不到——那时块体还没开始跑。

这也是为什么上下文管理器里写「清理」要放在 finally 里:异常从 yield 灌进来,finally 是唯一保证执行的收尾。

5. 异常被吞的三种死法,逐一排雷

用 @contextmanager 时踩坑的经典三连:

坑 现象 根因
yield 不在 try 里 块体一抛异常,finally 不执行,资源泄漏 yield 裸奔,throw 直接掀翻生成器
yield 返回值为 None with res() as r 拿到 None 忘了 yield value,as 绑了个空
enter 阶段就抛异常 块体不执行,但 __exit__ 也不会调 进屋子就塌了,没人负责拆屋

规律:拿资源的代码(yield 前)要自含清理或让它自然失败;还资源的代码(yield 后)必须包在 finally 里。

6. 协议本身 353 纳秒,但别在热点里开火锅店

实测 100 万次 with 的开销:

写法 每次耗时
with noop: pass(空协议) 353 ns
裸 try/finally: pass 60 ns
with open('NUL') as f: pass 42 250 ns

两个结论:协议本身(两次方法调用+异常帧)约 300 ns,约为裸 try/finally 的 6 倍;但真正的开销从来不在协议——open 的 42 微秒全是系统调用。在每秒百万次的循环里开合 with 确实该优化(提到循环外),但绝大多数场景,with 的 353 ns 连零头都算不上。为了「性能」把 with 拆成裸 try/finally,是用可维护性换 290 纳秒的亏本买卖。


一句话收尾:with 的本质是「两次调用加一个返回值」——enter 拿资源、exit 还资源、返回值决定异常生死;@contextmanager 把这一切架在 yield 上:异常不是被抓的,是从 yield 灌进去的。353 纳秒的协议费,买的是资源生命周期永远不会漏的保证。

posted @ 2026-10-21 08:39  小小张说故事  阅读(4)  评论(0)    收藏  举报