contextlib 是 Python 标准库里一组围绕上下文管理器设计的工具。它解决的核心问题很朴素:某项资源在使用前需要准备,使用结束后必须清理,而且无论中途是否抛出异常,清理动作都不能漏掉。
数据库连接、文件句柄、锁、网络会话、临时目录,以及 FastAPI 应用启动时加载的模型,都属于这种有明确生命周期的资源。contextlib 的价值,就是把这些成对出现的初始化与清理动作放在同一个结构中,减少资源泄漏和异常路径遗漏。
一、contextlib 究竟是什么
Python 的 with 语句依赖一种名为上下文管理器的协议。最常见的例子是文件操作。
python
with open("data.txt", "r", encoding="utf-8") as file:
content = file.read()
进入 with 代码块时,文件被打开;离开代码块时,文件会被关闭。即使读取过程中发生异常,关闭动作仍会执行。
上下文管理协议
同步上下文管理器包含两个特殊方法。
python
class ManagedResource:
def __enter__(self):
print("获取资源")
return self
def __exit__(self, exc_type, exc_value, traceback):
print("释放资源")
使用方式如下。
python
with ManagedResource() as resource:
print("使用资源")
它的执行过程可以理解为:
__exit__ 会收到异常类型、异常对象和追踪信息。它返回真值时,异常会被视为已经处理;返回假值或 None 时,异常继续向外传播。
异步场景采用对应的协议:
__aenter____aexit__async with
PEP 343 定义了 with 语句及其上下文管理机制,而 contextlib 在这套协议之上提供了更便捷、更易组合的工具。
二、contextlib 涉及的核心概念
理解 contextlib,关键是抓住三个词:生命周期、异常安全和组合管理。
1.资源生命周期
很多资源都有三个阶段:
- 创建或获取资源;
- 使用资源;
- 释放、关闭或回滚资源。
如果手写 try...finally,通常会是这样。
python
resource = acquire_resource()
try:
use_resource(resource)
finally:
release_resource(resource)
上下文管理器把同样的逻辑收进 with 中。
python
with managed_resource() as resource:
use_resource(resource)
两者在控制流程上很接近,但上下文管理器更适合复用,也更容易嵌套和组合。
2.异常安全
清理代码不能只放在正常路径上。例如下面的写法存在风险。
python
connection = open_connection()
run_query(connection)
connection.close()
一旦 run_query() 抛出异常,close() 就不会运行。
上下文管理器在语义上接近 try...finally,可以保证离开作用域时执行清理逻辑。这里的离开既包括正常结束,也包括异常、return、break 等控制流程。
3.生成器式上下文管理器
手写 __enter__ 和 __exit__ 有时略显笨重。contextlib.contextmanager 可以把一个生成器函数转换成同步上下文管理器。
python
from contextlib import contextmanager
@contextmanager
def managed_connection():
connection = open_connection()
try:
yield connection
finally:
connection.close()
调用时仍然使用普通的 with。
python
with managed_connection() as connection:
run_query(connection)
这里的 yield 是整个结构的分界线:
yield之前负责初始化;yield返回的值交给as后面的变量;yield之后负责清理;yield在一次上下文执行中只能出现一次。
如果 with 内部抛出异常,该异常会在 yield 所在位置重新出现。因此,生成器内部既可以通过 finally 清理资源,也可以捕获并处理特定异常。
4.异步上下文管理器
数据库连接池、异步 HTTP 客户端等资源,初始化和关闭过程本身可能需要 await。此时可以使用 asynccontextmanager。
python
from contextlib import asynccontextmanager
@asynccontextmanager
async def managed_client():
client = await create_client()
try:
yield client
finally:
await client.close()
调用方式是:
python
async with managed_client() as client:
await client.send_request()
FastAPI 是异步 Web 框架,因此 asynccontextmanager 在应用生命周期管理中十分常见。
三、contextlib 中常用的工具
contextlib 不只有两个装饰器。它还提供了一组适合不同资源管理场景的实用工具。
| 工具 | 主要用途 | 典型场景 |
|---|---|---|
contextmanager |
用生成器创建同步上下文管理器 | 文件、事务、锁 |
asynccontextmanager |
用异步生成器创建异步上下文管理器 | 数据库连接池、HTTP 客户端 |
closing |
离开作用域时调用对象的 close() |
不支持上下文协议的第三方对象 |
aclosing |
离开异步作用域时调用 aclose() |
异步生成器、异步流 |
suppress |
有选择地忽略异常 | 删除可能不存在的文件 |
nullcontext |
提供一个什么也不做的上下文 | 可选的上下文管理 |
redirect_stdout |
临时重定向标准输出 | 捕获命令输出、测试 |
ExitStack |
动态管理多个同步上下文 | 资源数量运行时才能确定 |
AsyncExitStack |
混合管理异步上下文和清理回调 | 多个异步客户端或连接 |
suppress 有选择地忽略异常
python
from contextlib import suppress
from pathlib import Path
with suppress(FileNotFoundError):
Path("cache.tmp").unlink()
这相当于只捕获并忽略 FileNotFoundError。不应写成过于宽泛的异常抑制,否则真正的程序错误也可能悄悄溜走。
nullcontext 处理可选资源
假设调用者有时传入已打开的文件,有时传入文件路径,可以用 nullcontext 统一控制流程。
python
from contextlib import nullcontext
def read_data(file_object=None):
context = (
nullcontext(file_object)
if file_object is not None
else open("data.txt", "r", encoding="utf-8")
)
with context as file:
return file.read()
ExitStack 动态组合资源
普通 with 更适合资源数量固定的情况。
python
with open("a.txt") as file_a, open("b.txt") as file_b:
...
如果文件数量在运行时才知道,ExitStack 更自然。
python
from contextlib import ExitStack
paths = ["a.txt", "b.txt", "c.txt"]
with ExitStack() as stack:
files = [
stack.enter_context(open(path, "r", encoding="utf-8"))
for path in paths
]
contents = [file.read() for file in files]
退出 ExitStack 时,已经成功进入的上下文会按照后进先出的顺序关闭。即使打开第三个文件时发生异常,前两个文件仍能得到妥善关闭。
AsyncExitStack 是它的异步版本,适合 FastAPI 中需要动态组合多个异步资源的场景。
四、FastAPI 中最重要的用途------应用生命周期
FastAPI 服务往往需要在开始接收请求前完成初始化,在停止服务时释放资源。例如:
- 建立数据库连接池;
- 创建 Redis 客户端;
- 创建共享的异步 HTTP 客户端;
- 加载机器学习模型;
- 启动后台任务;
- 在关闭时刷新缓冲区并断开连接。
FastAPI 推荐通过 lifespan 参数接收一个异步上下文管理器 。yield 之前是启动阶段,yield 之后是关闭阶段。
基本示例
python
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
print("应用启动,初始化资源")
app.state.model = load_model()
try:
yield
finally:
print("应用关闭,释放资源")
app.state.model = None
app = FastAPI(lifespan=lifespan)
整体流程如下。
yield 之前的代码执行完成后,FastAPI 才开始处理请求。服务器收到关闭信号后,会继续执行 yield 后面的代码。
管理数据库连接池
下面用抽象接口展示结构,具体方法会随数据库驱动变化。
python
from contextlib import asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
pool = await create_database_pool()
app.state.db_pool = pool
try:
yield
finally:
await pool.close()
app = FastAPI(lifespan=lifespan)
业务代码可以从 request.app.state 读取连接池。
python
from fastapi import Request
@app.get("/users")
async def list_users(request: Request):
pool = request.app.state.db_pool
async with pool.acquire() as connection:
return await connection.fetch("SELECT * FROM users")
这里存在两层生命周期:
- 数据库连接池与整个 FastAPI 应用同生共死;
- 单个数据库连接只服务于当前查询或请求。
将不同生命周期分开,代码会清楚许多,也能避免每个请求都重复创建昂贵的连接池。
五、管理多个 FastAPI 共享资源
真实项目通常不止一个共享资源。可以直接嵌套多个 async with,也可以使用 AsyncExitStack 集中管理。
python
from contextlib import AsyncExitStack, asynccontextmanager
from fastapi import FastAPI
@asynccontextmanager
async def lifespan(app: FastAPI):
async with AsyncExitStack() as stack:
database = await stack.enter_async_context(
create_database_context()
)
http_client = await stack.enter_async_context(
create_http_client_context()
)
redis = await stack.enter_async_context(
create_redis_context()
)
app.state.database = database
app.state.http_client = http_client
app.state.redis = redis
yield
AsyncExitStack 会记住成功注册的资源,并在退出时按相反顺序清理。假如 Redis 初始化失败,已经建立的 HTTP 客户端和数据库资源仍会被关闭。
这类结构尤其适合以下情况:
- 资源是否启用由配置决定;
- 资源数量在运行时变化;
- 同时存在同步和异步清理逻辑;
- 初始化过程可能在中途失败;
- 希望把清理责任集中到一个出口。
示意流程如下。
资源关闭顺序通常与创建顺序相反,类似叠盘子:后放上去的盘子先拿下来。
六、在 FastAPI 依赖注入中的用法
FastAPI 的 yield 依赖与上下文管理器有相似的生命周期语义。依赖在 yield 前获取资源,将资源交给路径函数,并在请求结束后执行 yield 后的清理代码。
请求级数据库会话
python
from collections.abc import AsyncIterator
from fastapi import Depends, FastAPI
app = FastAPI()
async def get_database_session() -> AsyncIterator[DatabaseSession]:
session = DatabaseSession()
try:
yield session
finally:
await session.close()
@app.get("/users/{user_id}")
async def get_user(
user_id: int,
session: DatabaseSession = Depends(get_database_session),
):
return await session.get_user(user_id)
这类依赖通常不必再添加 @asynccontextmanager。FastAPI 能直接识别带 yield 的依赖函数,并负责驱动它的进入与退出过程。
复用已有的异步上下文管理器
如果资源管理逻辑还需要在 FastAPI 之外使用,可以先定义独立上下文管理器,再通过依赖包装。
python
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@asynccontextmanager
async def database_session_context():
session = DatabaseSession()
try:
yield session
finally:
await session.close()
async def get_database_session() -> AsyncIterator[DatabaseSession]:
async with database_session_context() as session:
yield session
这种分层方式很实用:
database_session_context是通用资源管理工具;get_database_session负责接入 FastAPI 依赖系统;- 命令行任务、后台任务和测试也能复用同一套资源管理逻辑。
应用级与请求级资源的区别
| 资源级别 | 存活时间 | 适合管理的对象 | 常见实现 |
|---|---|---|---|
| 应用级 | 从服务启动到关闭 | 连接池、模型、共享客户端 | lifespan |
| 请求级 | 从请求开始到请求结束 | 数据库会话、事务、临时文件 | yield 依赖 |
| 局部代码块 | 某段操作执行期间 | 单个连接、锁、文件 | with 或 async with |
判断资源应该放在哪里,主要看它的生命周期。创建成本高且能够安全共享的对象适合放入 lifespan;与单次请求状态紧密相关的对象,更适合放入依赖函数。
七、事务管理与异常回滚
上下文管理器非常适合表达数据库事务的语义:成功则提交,失败则回滚。
python
from contextlib import asynccontextmanager
@asynccontextmanager
async def transaction(session):
try:
yield session
except Exception:
await session.rollback()
raise
else:
await session.commit()
在 FastAPI 依赖中复用:
python
async def get_transactional_session():
async with create_session() as session:
async with transaction(session):
yield session
业务函数只需关注业务逻辑。
python
@app.post("/orders")
async def create_order(
payload: OrderCreate,
session=Depends(get_transactional_session),
):
order = await insert_order(session, payload)
await update_inventory(session, payload.items)
return order
如果库存更新失败,异常会沿调用栈传播到事务上下文,触发回滚。回滚完成后再次 raise,让 FastAPI 的异常处理机制继续处理错误。
除非确实要把异常视为成功,否则不要在上下文管理器中捕获异常后保持沉默。资源是清理干净了,但错误也可能因此被藏进地毯下面。
八、测试中的用途
FastAPI 的 TestClient 本身也可以作为上下文管理器使用。需要测试应用启动与关闭逻辑时,应通过 with 进入客户端。
python
from fastapi.testclient import TestClient
def test_application_lifespan():
with TestClient(app) as client:
response = client.get("/health")
assert response.status_code == 200
进入 with TestClient(app) 时,测试客户端执行 FastAPI 的启动生命周期;退出时执行关闭生命周期。
如果只实例化客户端却不进入上下文,测试环境中的 lifespan 可能不会按照预期运行。
python
client = TestClient(app)
因此,测试数据库连接池、模型加载、缓存初始化等启动行为时,推荐使用上下文形式。
九、常见误区与实践建议
把短生命周期资源放进全局状态
数据库会话、事务和临时文件通常不应放在 app.state 中长期共享。它们往往携带请求状态,并发共享可能产生数据污染或事务混乱。
更合理的结构是:
app.state保存线程安全或协程安全的长期资源;- FastAPI 依赖保存请求级资源;
- 局部
with保存操作级资源。
忘记在 yield 后清理
下面的上下文管理器没有释放资源。
python
@asynccontextmanager
async def broken_context():
client = await create_client()
yield client
更稳妥的写法是使用 try...finally。
python
@asynccontextmanager
async def managed_context():
client = await create_client()
try:
yield client
finally:
await client.close()
在生命周期中执行永久阻塞任务
yield 之前的代码必须执行完毕,应用才能开始接收请求。如果直接等待一个永不结束的任务,FastAPI 就会一直停留在启动阶段。
后台任务应创建为独立任务,并在关闭阶段取消和等待。
python
import asyncio
from contextlib import asynccontextmanager, suppress
@asynccontextmanager
async def lifespan(app):
task = asyncio.create_task(background_worker())
try:
yield
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
混淆同步和异步上下文
同步上下文使用:
python
with resource:
...
异步上下文使用:
python
async with resource:
...
若资源的进入或退出需要 await,就应实现异步上下文协议,并使用 asynccontextmanager。不能仅因为路径函数写成了 async def,就把所有同步对象都改成 async with。对象支持哪一种协议,才是判断依据。
结语
contextlib 可以看作 Python 的资源生命周期工具箱。它把初始化、使用、异常处理和清理收拢在清晰的作用域中,让代码在正常路径和异常路径上保持一致。
在 FastAPI 项目里,可以按生命周期划分职责:
- 应用启动到关闭的共享资源交给
lifespan和asynccontextmanager; - 单次请求使用的会话、事务交给带
yield的依赖; - 数量动态变化的资源交给
ExitStack或AsyncExitStack; - 测试生命周期时,用
with TestClient(app)驱动启动和关闭事件。
掌握这套思路后,contextlib 就不再只是几个装饰器。它会成为组织数据库、缓存、网络客户端、后台任务和模型资源的一条主线,让 FastAPI 服务更可靠,也让清理代码不再四处救火。
参考资料
Python Documentation contextlib Utilities for with Statement Contexts
docs.python.org/3/library/c...
PEP 343 The with Statement
FastAPI Documentation Lifespan Events
fastapi.tiangolo.com/advanced/ev...
FastAPI Documentation Testing Events Lifespan and Startup Shutdown