很多人写 with 语句时只关心「打开、关闭」这一层,能跑通就不深究。可一旦业务里出现异常被悄悄吞掉、__exit__ 里抛出新的异常覆盖了原始异常、或者 GeneratorExit 让 @contextmanager 报出 RuntimeError: generator didn't stop after throw(),就会调上一两个小时。这篇文章把 with 语句背后的协议拆开讲:__enter__ 的返回值落在了哪个名字上、__exit__ 的三个参数分别是什么、返回 True 究竟吞掉了什么、异常在 __exit__ 内部再抛出时解释器如何组织异常链,以及 contextlib 在背后替你做了哪些容易误判的事。所有结论都在 CPython 3.10/3.11/3.12 上实际验证过。
with 语句的展开:三步语义与名字绑定
PEP 343 定义的语义,可以精确地展开成下面这段等价代码。理解这段是后面所有细节的地基。
manager = EXPR
enter = type(manager).__enter__
exit = type(manager).__exit__
value = enter(manager)
hit_except = False
try:
TARGET = value # 只有写了 as TARGET 才有这一步
SUITE
except BaseException as exc:
hit_except = True
if not exit(manager, type(exc), exc, exc.__traceback__):
raise
finally:
if not hit_except:
exit(manager, None, None, None)
从这段展开里可以读出几个经常被忽略的事实:
__enter__和__exit__都是通过 类型 查找的,不是实例属性。所以给实例动态挂一个obj.__exit__ = ...对已有的with语句不起作用,必须挂在类上。as后面的名字绑定的是__enter__的返回值,不是上下文管理器本身。文件对象f能写是巧合——open()的__enter__返回self。__exit__无异常时收到(None, None, None),有异常时收到(type, value, traceback),其中traceback是这个异常的__traceback__对象,用于重抛。- 异常发生时是否继续向外传播,只取决于
__exit__的返回值是否为真值。返回None、0、False、[]都等同于「不吞」。
写一个最小的探针来观察参数:
class Probe:
def __enter__(self):
print("enter, return 42")
return 42
def __exit__(self, exc_type, exc, tb):
print(f"exit: type={exc_type!r} value={exc!r} tb_lines={len(tb.__traceback__.tb_frame.f_code.co_name) if tb else 0}")
return False
with Probe() as v:
print("bound value:", v)
int("not-a-number")
输出(Python 3.11):
enter, return 42
bound value: 42
exit: type=<class 'ValueError'> value=ValueError("invalid literal for int() with base 10: 'not-a-number'") tb_lines=3
Traceback (most recent call last):
File "demo.py", line 11, in <module>
int("not-a-number")
ValueError: invalid literal for int() with base 10: 'not-a-number'
可以看到 __exit__ 被调用之后,异常仍带着原来的 traceback 向上抛,而且 traceback 里包含 with 所在的帧,调用栈没有被破坏。
__enter__ 抛异常时,__exit__ 不会被调用
这是最容易踩的一个坑:__enter__ 位于 try 之外。如果 __enter__ 自己抛异常,__exit__ 完全不会执行。这意味着「获取资源」的逻辑一旦在 __enter__ 里失败,你在 __exit__ 里写的清理逻辑根本没有机会跑。
class SafeFile:
def __init__(self, path):
self.path = path
self.f = None
def __enter__(self):
self.f = open(self.path, "r", encoding="utf-8") # 文件不存在则抛 FileNotFoundError
return self.f
def __exit__(self, exc_type, exc, tb):
print("cleanup") # 文件不存在时,这行永远不会打印
if self.f:
self.f.close()
return False
with SafeFile("no_such_file.txt") as f: # FileNotFoundError
pass
实际报错:
Traceback (most recent call last):
File "demo.py", line 9, in <module>
with SafeFile("no_such_file.txt") as f:
File "demo.py", line 3, in __enter__
self.f = open(self.path, "r", encoding="utf-8")
FileNotFoundError: [Errno 2] No such file or directory: 'no_such_file.txt'
注意 traceback 里没有 cleanup。正确做法是把可能失败的前半段放在 __enter__ 内部自己用 try/except 兜底并释放已拿到的部分资源,保证 __enter__ 要么整体成功,要么不留下半成品:
import socket
class Conn:
def __init__(self, host, port):
self.host, self.port = host, port
self.sock = None
def __enter__(self):
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.settimeout(3.0)
try:
self.sock.connect((self.host, self.port))
except OSError:
self.sock.close() # 自行回收,因为 __exit__ 不会来
self.sock = None
raise
return self.sock
def __exit__(self, exc_type, exc, tb):
if self.sock is not None:
self.sock.close()
return False
try:
with Conn("127.0.0.1", 1) as s:
s.sendall(b"ping")
except (ConnectionRefusedError, TimeoutError) as e:
print("连接失败:", e)
另一类隐蔽情形:__enter__ 抛的是 StopIteration,而它出现在 with 内部(不是 __enter__)时,若外层是生成器,会被 for 循环当成「迭代结束」静默吞掉。PEP 479 在 Python 3.7 之后把生成器内部冒出的 StopIteration 转成了 RuntimeError: generator raised StopIteration,但上下文管理器里的 StopIteration 不受此保护,__exit__ 里就应该显式处理。
__exit__ 返回真值:吞异常的边界在哪
返回 True 之后,被吞掉的是整个异常,而不只是某一种类型。因此如果 __exit__ 只打算处理 KeyError,其余异常必须显式放行:
class SuppressKeyError:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return exc_type is KeyError # 只有 KeyError 返回 True
with SuppressKeyError():
d = {}
d["missing"] # 被吞,继续往下走
print("after KeyError")
try:
with SuppressKeyError():
1 / 0 # 不是 KeyError,返回 False,正常抛出
except ZeroDivisionError as e:
print("caught:", e)
输出:
after KeyError
caught: division by zero
用 exc_type is KeyError 而不是 issubclass(exc_type, KeyError),是因为 exc_type 是精确类型;如果希望匹配子类(例如 sqlite3.IntegrityError 属于 sqlite3.DatabaseError),就该写 exc_type is not None and issubclass(exc_type, SomeError)。标准库 contextlib.suppress 就是这种写法:
import contextlib
with contextlib.suppress(FileNotFoundError):
open("nope.txt")
print("继续执行")
还有一个容易忽略的点:__exit__ 返回 True 时,Python 只是不重抛,异常对象本身、traceback 帧以及 sys.exc_info() 的清理都照常进行。但在 3.7+ 上,如果 __exit__ 内部又发生了异常(即发生了「吞掉一个异常的同时抛出另一个」),解释器会把原始异常挂在 __context__ 上,输出里能看到 During handling of the above exception, another exception occurred。要显式断链就 raise ... from None,要保留因果就用 raise ... from exc。
__exit__ 自己抛异常:原始异常被覆盖还是链接
这是异常传递里最反直觉的一段。__exit__ 执行时,若它抛出新异常,新异常会取代原始异常成为对外抛出的那个,原始异常只作为 __context__ 出现。如果此时 __exit__ 返回 True,那个 True 没有意义——异常已经抛出,返回值被忽略。
class Bad:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
raise RuntimeError("cleanup failed")
with Bad():
raise ValueError("business failed")
输出(3.12):
Traceback (most recent call last):
File "demo.py", line 9, in <module>
raise ValueError("business failed")
ValueError: business failed
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "demo.py", line 6, in __exit__
raise RuntimeError("cleanup failed")
RuntimeError: cleanup failed
对外暴露的是 RuntimeError,调用方按 except ValueError 是捕不到的。真实的线上事故往往长这样:数据库提交失败本来应该抛 IntegrityError,结果 __exit__ 里的 conn.close() 先报了个 ConnectionResetError,把根因盖住了,排查时只能顺着 __context__ 翻。
解决办法有两条,按场景选:
- 清理动作必须「尽力而为且不致命」。释放锁、关连接、删临时文件这类操作,各自套一层
try/except Exception,把清理失败降级为日志。 - 如果清理失败必须暴露,就显式用
raise ... from exc建立因果,让 traceback 读起来是「清理失败,因为它本来在处理 xxx」。
import logging
logger = logging.getLogger(__name__)
class Resilient:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
try:
self._close()
except Exception:
if exc is not None:
logger.exception("清理失败,原始异常为 %r", exc) # 不掩盖根因
else:
raise # 无原始异常时正常暴露
return False
def _close(self):
raise OSError("disk gone")
判断依据就是 exc is not None:有原始异常时清理失败只记录,保证调用方拿到的是真实业务异常;没有原始异常时说明清理失败本身就是这次 with 的唯一问题,应该抛出去。
@contextmanager 的异常路径:yield 处的 throw 与 finally
contextlib.contextmanager 把生成器的 next() / throw() 映射到 __enter__ / __exit__。最关键的映射关系是:生成器正常结束 → __exit__ 返回 True;生成器把 throw 进来的异常重抛 → __exit__ 返回 False。这解释了一个经典困惑:为什么下面的代码会把异常吞掉。
from contextlib import contextmanager
@contextmanager
def swallow_all():
try:
yield
except Exception:
pass # 生成器没有重抛,于是 __exit__ 返回 True → 吞掉
with swallow_all():
raise ValueError("被吃掉了")
print("执行到了这里")
反过来,try/finally 里的 finally 会在 gen.throw() 触发时执行,但若 finally 里 return 或 break,异常同样被终结。想「只观测不改变语义」,必须原样重抛:
from contextlib import contextmanager
@contextmanager
def observe():
try:
yield
except BaseException as e:
print("观测到:", type(e).__name__)
raise # 必须重抛,否则 __exit__ 返回 True
finally:
print("finally 一定执行")
try:
with observe():
raise KeyError("k")
except KeyError:
print("异常确实传播出来了")
当 with 体内是 return/break/continue 时,GeneratorExit 是不会出现的——生成器以正常结束的方式走完 yield 之后的部分。真正的 GeneratorExit 只在生成器被 close() 时出现,比如被 GC 回收或者外层生成器被关闭。此时 @contextmanager 会调用 gen.close(),如果你的生成器在 finally 里再 yield,就会得到:
RuntimeError: generator ignored GeneratorExit
而另一种常见报错是这样产生的:
from contextlib import contextmanager
@contextmanager
def broken():
try:
yield
finally:
yield # 在清理阶段又 yield
with broken():
pass
RuntimeError: generator didn't stop after throw()
结论很直接:@contextmanager 修饰的生成器必须恰好 yield 一次。需要 yield 多次的场景应该老老实实写类形式的上下文管理器,或者拆成两个管理器。
还有一个不显眼但很有用的细节:@contextmanager 的 as 拿到的是 yield 后面的值,而不是管理器对象;如果 yield 前面没有任何值(裸 yield),as 得到的名字就是 None。
可重入、异常组与一些必须知道的边界
先看可重入。同一个上下文管理器实例被嵌套进入时,很多手写实现会出错,标准库 contextlib.ExitStack 和 threading.Lock 的用法值得对照:
import threading
lock = threading.Lock()
with lock: # __enter__ 调 acquire()
with lock: # 同一个线程再次 acquire(),非重入锁会阻塞
pass
上面这段会永久阻塞。如果确实需要可重入,用 threading.RLock,或者自己记录持有者与计数:
import threading
class ReentrantLock:
def __init__(self):
self._lock = threading.RLock()
self._depth = 0
def __enter__(self):
self._lock.acquire()
self._depth += 1
return self
def __exit__(self, exc_type, exc, tb):
self._depth -= 1
self._lock.release()
return False
再看异常组。Python 3.11 引入 ExceptionGroup 之后,except* 与 __exit__ 的交互需要明确一点:with 体内抛出的异常组,在 __exit__ 里收到的是整个 ExceptionGroup,exc_type 是 ExceptionGroup(或其子类如 BaseExceptionGroup),而不是组内某个子异常的类型。except* 的分派是在更外层完成的。
class Watch:
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
print("type:", exc_type.__name__, "| leaves:",
[type(e).__name__ for e in exc.exceptions] if exc else None)
return False
try:
with Watch():
raise ExceptionGroup("batch", [ValueError("a"), TypeError("b")])
except ExceptionGroup as eg:
print("outer:", eg.message)
type: ExceptionGroup | leaves: ['ValueError', 'TypeError']
outer: batch
如果你想在 __exit__ 里只处理其中一类,需要自己遍历 exc.exceptions 并用 except* 重建,而不是依赖 exc_type——这一点与 3.10 及以前的单异常模型完全不同,迁移时要特别注意。
最后是性能与写法取舍。with 的协议开销极小(一次类型查找加两次调用),不要为了「少一层缩进」而手动 try/finally。contextlib.ExitStack 适合「不确定数量、动态注册」的清理;contextlib.AsyncExitStack 对应异步场景;contextlib.nullcontext 适合让某个参数可选地参与 with:
import contextlib
def process(path, *, quiet=False):
cm = contextlib.nullcontext() if quiet else open(path, "r", encoding="utf-8")
with cm as f:
return f.read() if f is not None else ""
另外,__exit__ 里拿到 tb 后不要长期持有异常对象或 traceback,否则会拖住整条调用栈里所有局部变量,造成内存泄漏——这在 Web 服务的错误处理中间件里是真实发生过的问题。sys.exc_info() 在 except 块退出后会被清空,但只要你把异常对象存进了列表,帧就会一直活着。
小结
__enter__在try之外,失败时__exit__不执行,资源获取必须自己兜底。as绑定的是__enter__的返回值,__enter__/__exit__走类型查找。__exit__返回真值才是「吞异常」,吞的是整个异常,精确匹配要用exc_type is X。__exit__抛新异常会覆盖原异常,原异常退化为__context__;清理逻辑要按exc is not None决定是否放行。@contextmanager中生成器不重抛就等于返回True;必须恰好 yield 一次,否则触发RuntimeError: generator didn't stop after throw()。- 3.11+ 的异常组在
__exit__里以整体形式出现,按类型分派要自己遍历。
FAQ
1. 为什么我的 __exit__ 返回了 True,异常还是抛出来了?
先确认 __exit__ 里没有抛新异常——一旦 __exit__ 自身抛异常,返回值被忽略。其次确认异常是不是在 __enter__ 里抛的:那种情况 __exit__ 压根没被调用。最后检查是不是 BaseException 的子类被捕获但你没匹配到(KeyboardInterrupt、SystemExit 不是 Exception 子类)。
2. @contextmanager 里 except Exception: pass 为什么会吞异常?
因为生成器把 gen.throw() 进来的异常吃掉后正常结束,contextlib 判定生成器没有重抛,于是 __exit__ 返回 True。想只记录,就在 except 块末尾 raise;想放行非目标异常,就写 except KeyError: 而不是 except Exception:。
3. RuntimeError: generator didn't stop after throw() 怎么修?
生成器在收到异常后没有立即结束,而是又 yield 了一次(常见于把 yield 写进 finally 或 except 分支)。删掉额外的 yield,保证 @contextmanager 的生成器恰好 yield 一次。需要多阶段产出请改用类实现或 ExitStack。
4. 在 __exit__ 里重抛时,用 raise 还是 raise exc?
在 __exit__ 函数体内直接写 raise 是合法的——此时异常正处于「正在处理」状态,等价于重抛原始异常;但更推荐写 raise exc.with_traceback(tb) 或直接 return False 让解释器代劳,避免手工重建 traceback 时丢失帧。
5. 上下文管理器需要支持嵌套使用吗?
协议本身不要求,同一个实例被嵌套 with 时语义完全由你实现决定(比如计数、或者直接拒绝)。threading.Lock 不可重入,RLock 可重入;open() 返回的文件对象嵌套使用会出现两个位置共享同一个文件偏移,应避免。
6. 异步上下文管理器的异常传递有区别吗?
__aenter__/__aexit__ 的异常语义与同步版完全一致:__aexit__ 返回真值吞异常,__aenter__ 失败不调用 __aexit__。区别只在于等待过程可能被 CancelledError 打断,而 CancelledError 在 3.8+ 是 BaseException 子类,except Exception 捕不到,清理逻辑里要显式处理。