在没有 with 语句以前,开发者需要手动编写 try/finally 来保证文件、连接或锁被正确释放。比如打开文件后,一旦中间逻辑抛出异常,close() 调用就可能被跳过。with 语句的出现把资源获取和释放动作强制绑定到代码块边界,从语法层面排除了这类遗漏。要理解它的工作原理,不能只停留在“自动调用 close”的表象,而应看到背后的一套协议:上下文管理协议。

一、with语句的底层执行流程
Python 解释器遇到 with 语句时,会先对 with 后面的表达式求值,得到一个上下文管理器对象。紧接着,解释器调用该对象的 __enter__ 方法,该方法的返回值会被赋值给 as 关键字后面的变量。如果 with 语句中没有 as 子句,__enter__ 的返回值会被直接丢弃。然后代码块开始执行,无论代码块正常结束还是抛出异常,离开 with 代码块时都会调用同一个对象的 __exit__ 方法。
从语义上看,下面两段代码非常接近。第一段使用传统的 try/finally,第二段使用 with:
# 使用 try/finally
f = open('data.txt')
try:
content = f.read()
finally:
f.close()
# 使用 with
with open('data.txt') as f:
content = f.read()
不过 with 语句并不是简单把 try/finally 包一层。它额外把异常信息传给 __exit__ 方法,允许上下文管理器在清理资源的同时观察或处理异常。__exit__ 方法接收三个参数:异常类型、异常实例和 traceback 对象。如果代码块没有异常,这三个参数都是 None。如果 __exit__ 返回 True,异常会被抑制,with 后面的代码继续执行;如果返回 False 或 None,异常继续向外传播。这个返回值机制是 try/finally 不具备的。
二、自定义上下文管理器:__enter__ 与 __exit__ 方法
只要一个对象同时实现了 __enter__ 和 __exit__ 两个方法,它就可以作为上下文管理器使用。__enter__ 在进入 with 块时执行,通常会获取资源并返回资源对象;__exit__ 在离开块时执行,负责释放资源。下面这个 ManagedFile 类把文件打开和关闭封装起来,并在异常发生时打印类型。
class ManagedFile:
def __init__(self, name):
self.name = name
def __enter__(self):
self.file = open(self.name, 'w')
return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
self.file.close()
if exc_type is not None:
print(f'捕获到异常类型: {exc_type.__name__}')
return False
with ManagedFile('test.txt') as f:
f.write('hello')
上面的 __exit__ 返回 False,所以如果 write 过程中出现异常,异常仍然会向上层抛出,但文件已经被关闭。假如把返回值改成 True,异常会被吞掉,调用方就无法感知错误。因此,除非你非常清楚自己在做什么,否则不要轻易抑制异常。数据库连接、文件句柄、线程锁等资源通常需要返回 False 来保持异常透明。
__enter__ 也可以返回与资源无关的对象,比如返回一个封装后的 API 客户端,或者返回自身。关键是要清楚 as 后面拿到的值来自 __enter__ 的返回值,而不是 with 后面的表达式本身。这个细节在阅读一些框架源码时很有用,例如某些库的 with 语句返回的是会话对象,而不是连接对象。
三、contextlib 模块:用生成器快速构建上下文管理器
如果每个资源都要写一个类,代码会显得冗余。Python 标准库中的 contextlib 模块提供了 contextmanager 装饰器,可以把一个生成器函数直接转换成上下文管理器。生成器 yield 之前的代码相当于 __enter__,yield 之后直到函数结束的代码相当于 __exit__。下面是一个等价于 ManagedFile 的写法。
from contextlib import contextmanager
@contextmanager
def managed_file(name):
f = open(name, 'w')
try:
yield f
finally:
f.close()
with managed_file('test.txt') as f:
f.write('world')
这种写法更直观,异常处理也集中在一个 try/finally 结构中。如果 yield 之前的代码抛出异常,生成器不会进入到 with 块;如果 with 块内部抛出异常,它会被抛回到生成器的 yield 位置,此时 finally 中的清理逻辑会执行,随后异常继续传播。需要注意的是,yield 只能出现一次,因为上下文管理器只允许进入和退出一次。
contextlib 还提供了 ExitStack,适合在不知道需要管理多少个资源时使用。例如从配置列表中打开多个文件,或者同时管理文件和数据库连接。ExitStack 能保证所有注册的资源在离开 with 块时按后进先出的顺序关闭。下面是一个简单示例。
from contextlib import ExitStack
with ExitStack() as stack:
files = []
for name in ['a.txt', 'b.txt', 'c.txt']:
f = stack.enter_context(open(name, 'w'))
files.append(f)
# 写入数据
for f in files:
f.write('data')
ExitStack 的 enter_context 方法会把资源注册到栈中,即使中途某个资源打开失败,之前已经打开的资源也会被正确释放。对于需要动态组合上下文管理器的场景,它比手写嵌套 with 清晰得多。
四、异步上下文管理器与 async with
在异步编程中,很多资源操作本身就是异步的,比如建立数据库连接、获取 HTTP 会话。同步的 __enter__ 和 __exit__ 无法在等待异步操作时让出事件循环,因此 Python 引入了异步上下文管理协议,对应方法是 __aenter__ 和 __aexit__,配合 async with 语句使用。
import asyncio
class AsyncResource:
async def __aenter__(self):
await asyncio.sleep(0.01)
print('异步资源已获取')
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await asyncio.sleep(0.01)
print('异步资源已释放')
return False
async def main():
async with AsyncResource() as r:
print('执行业务逻辑')
asyncio.run(main())
async with 的执行过程和同步 with 类似:进入时调用 __aenter__,拿到返回值后执行异步代码块,离开时调用 __aexit__。区别在于这两个方法都返回可等待对象,事件循环会在等待期间调度其他协程。__aexit__ 同样接收异常信息,也可以通过返回 True 来抑制异常,但异步场景下更要小心,因为吞掉异常可能导致协程静默失败。
许多异步库已经实现了异步上下文管理协议,比如 aiohttp 的 ClientSession、异步数据库连接池等。掌握 __aenter__ 和 __aexit__ 之后,你可以为自己封装的异步资源提供同样简洁的 with 风格用法,提升代码可读性和健壮性。

