本文摘要:FastAPI 漏写 await 会静默返回 coroutine,副作用丢失。从响应体字符串、日志空白、副作用消失三种症状逐层定位。修复需对齐路由与函数的同步异步声明,行为随版本变化需实测确认。
一、问题与结论
某 FastAPI 项目中,开发者写了同步路由函数def create_order,内部调用async def process_payment处理支付。接口返回 HTTP 200,响应体却是:
{"status":"ok","result":"<coroutine object process_payment at 0x7f8a2b3c4d00>"}数据库中没有这笔订单,支付从未执行,日志里没有任何报错。客户端收到"成功",实际什么都没做。
结论:Python 的async def函数被调用时返回 coroutine 对象,不会执行内部代码。coroutine 未被await驱动时,函数体内的数据库写入、外部请求、异常处理全部不执行。FastAPI 不对此抛异常或打警告——它把 coroutine 对象当作普通值交给 JSON 序列化器处理。定位时需要确认两件事:路由声明是def还是async def,以及被调用函数声明是否为async def、调用处是否正确await。
二、排查与选择依据
三种症状从不同角度暴露同一个问题,看到任意一个即可启动排查:
| 症状 | 观察到的现象 | 根因 |
|---|---|---|
| 响应体出现 coroutine 字符串 | JSON 中有"<coroutine object at 0x...>" | def路由调async def函数,未 await |
| 日志零异常 | 接口 200,无 error 也无 warning | coroutine 未驱动,内部try/except不触发 |
| 副作用不生效 | 数据库未写入、外部调用未发生 | coroutine 内部代码从未执行 |
排查顺序:
- 看响应体。如果 JSON 中出现
<coroutine object ...>字符串,基本可以确认是 coroutine 未被 await。 - 看路由声明。
def路由在线程池中运行,调用async def函数后不会自动 await;async def路由在事件循环中运行,顶层漏await的行为依赖 FastAPI 版本。 - 看被调用函数。只有
async def函数才会返回 coroutine 对象,def函数不会。 - 看调用处。确认是否漏写
await,或者是否在不恰当的上下文中调用了异步函数。
如果症状不明显(响应体被中间件处理过或被前端框架遮蔽),可以在代码中加一个临时调试端点来检查返回值类型:
@app.get("/_debug")asyncdef_debug():result=write_to_db("probe")return{"type":type(result).__name__,"repr":repr(result)}调用后观察type字段:如果输出"coroutine"而非"str",说明调用方式有问题。
在代码审查中,这类错误通常出现在两种过渡场景:一是项目从纯同步逐步引入异步库(如asyncpg、httpx.AsyncClient),部分旧路由还是def;二是新成员不熟悉 FastAPI 的双模式路由机制,在async def路由中沿用了同步写法。两种情况下只需检查一个规则:async def函数在调用处必须有await,或者被传给asyncio.run()显式驱动。
替代方案与取舍
| 方案 | 选择条件 | 代价 | 边界 |
|---|---|---|---|
路由改async def+ 补await | 调用链全异步 | 同步阻塞操作会阻塞事件循环 | 适合纯异步链路 |
同步路由中用asyncio.run() | 只桥接一个异步函数 | 每次请求新建事件循环,开销高 | 不适合高并发 |
CI 中用ruff --select ASYNC拦截 | 合并前预防 | 无法覆盖所有运行时场景 | 仅作辅助手段 |
中间件用asyncio.iscoroutine()兜底 | 生产环境防线 | 增加每请求开销 | 只能发现,不能预防 |
如果路由本身就是纯同步的(内部只用同步 I/O),不需要改成async def。同步路由在 FastAPI 线程池中运行是设计预期,不会出现本节讨论的 coroutine 问题。不要为了消除 lint 告警而强行改路由声明——这可能引入事件循环阻塞的新问题。
三、关键原理
FastAPI 根据路由函数声明自动选择执行机制,这是框架的核心设计之一:
def路由:放入线程池执行。线程池中调用async def函数只返回 coroutine 对象——没有事件循环来驱动它,函数体内的代码一行都不会执行。async def路由:在事件循环中执行。如果漏写await,顶层返回的 coroutine 在部分 FastAPI 版本中可能被框架自动 await,但这是 Starlette 底层的版本相关行为,不能作为可靠依赖来设计代码。
这个机制要求调用链的声明方式和调用方式保持一致:在同步上下文中调用异步函数,或在异步上下文中漏掉await,都会打破这个契约。
从 Python 运行时的角度看,coroutine 是一个有限状态机。调用async def函数只是创建了这个状态机对象,并没有开始执行。只有当事件循环(或await、asyncio.run()等驱动方式)将控制权交给它时,状态机才会从第一步开始逐步推进。coroutine 被 garbage collected 时,Python 会尝试关闭它,但如果它从未执行过任何步骤,这个关闭操作不会触发任何内部逻辑。
关于 Python 的 RuntimeWarning:coroutine 对象被创建后如果没有被 await 也没被显式关闭,Python 会在垃圾回收时触发RuntimeWarning: coroutine ... was never awaited。但这个 warning 依赖 GC 的触发时机,可能在请求结束后几秒甚至更久才出现,且需要将 warning 级别提升到日志配置中才能捕获——不能作为生产环境的可靠告警信号。
四、可运行示例
环境:Python 3.11+,安装依赖pip install fastapi uvicorn
importasynciofromfastapiimportFastAPI app=FastAPI()asyncdefwrite_to_db(data:str)->str:awaitasyncio.sleep(0.1)print(f"[副作用] 写入完成:{data}")returnf"saved:{data}"@app.get("/wrong")defwrong():returnwrite_to_db("data-1")@app.get("/right")asyncdefright():returnawaitwrite_to_db("data-2")操作步骤:
uvicorn main:app--port8000# 另开一个终端执行:curlhttp://localhost:8000/wrongcurlhttp://localhost:8000/right预期输出:
/wrong:响应体中result字段包含<coroutine object ...>字符串;服务端终端未打印[副作用] 写入完成:>五、验证结果与边界以下结论已在 Python 3.11 + FastAPI 0.115 上验证:
def路由调用async def函数,返回值为 coroutine 对象,内部代码不执行,副作用完全丢失。- 未 await 的 coroutine 不触发内部异常处理,日志零输出。
async def路由中漏写await的行为依赖版本,未在所有 FastAPI 版本上系统验证。
不适用场景:
- 路由声明为
def且内部只用同步 I/O——不存在 coroutine 问题,无需改造。 async def函数已通过asyncio.run()或await显式驱动——不会返回未驱动的 coroutine。- 使用
asyncio.create_task()显式调度——task 被事件循环驱动执行,不属于本文讨论的静默失败模式。
关于同步路由中用
asyncio.run()的一个注意事项:asyncio.run()会创建一个新的事件循环,执行完毕后关闭它。在低并发场景下这不是问题,但在高并发下(如每秒数百个请求),事件循环的反复创建会增加 CPU 开销和内存分配压力。此时应考虑将路由改为async def并沿异步链路调用。思考
- 同步路由中用
asyncio.run()桥接异步库,在高并发下是否值得重构为全异步链路? ruff的ASYNC规则能覆盖多少实际场景,能否替代部分人工 review?
参考资料
- FastAPI 官方文档 - Async / Await
- Python 3.11 asyncio 文档 - asyncio-task
- ruff 官方文档