Python 上下文管理器:__enter__ 与 __exit__ 的异常传递细节

很多人写 with 语句时只关心「打开、关闭」这一层,能跑通就不深究。可一旦业务里出现异常被悄悄吞掉、__exit__ 里抛出新的异常覆盖了原始异常、或者 GeneratorExit 让 @contextmanager 报出 RuntimeError: generator didn't stop after throw(),就会调上一两个小时。这篇文章把 w

很多人写 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__ 翻。

解决办法有两条,按场景选:

  1. 清理动作必须「尽力而为且不致命」。释放锁、关连接、删临时文件这类操作,各自套一层 try/except Exception,把清理失败降级为日志。
  2. 如果清理失败必须暴露,就显式用 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 捕不到,清理逻辑里要显式处理。