症状
决定性实验能区分“循环停摆”与“池耗尽”:在重负载同步端点运行的同时并发打平凡异步端点——若连异步端点都冻结,说明阻塞代码位于某个 async def 里,事件循环本身被卡死;若只有同步端点排队而异步照常飞快,则是共享线程池耗尽。再确认池大小:anyio 的 current_default_thread_limiter().limit 默认 40,这 40 个令牌服务进程内所有同步路由与同步依赖,50 个并发的重调用必然让最后 10 个排队。延迟聚类在慢端点时长的整数倍上,是排队论的可见指纹。
诊断思路
先按声明方式给每个路由分类:sync def 上线程池、async def 直跑事件循环——声明决定了故障模式;再做并发对照实验测量停摆范围;最后检查 limiter 限额与同步依赖(共享依赖里做阻塞 IO 常被忽略,它同样消耗池令牌)。
根因
FastAPI 把 sync def 路由放进全进程共享的 AnyIO 线程池(默认 40 线程)。阻塞端点饱和线程池时,其他所有同步路由排在它后面;而 async def 路由直接调用阻塞库时,唯一的事件循环本身停摆——后者更糟,连异步端点一起冻结。
修复与验证
按工作类型安置:适度阻塞 IO 保持 sync def 走线程池;工作对线程友好(IO、释放 GIL)时才调高 limiter 限额;CPU 密集工作彻底离开线程——ProcessPoolExecutor 加 run_in_executor,或任务队列。铁律是 async def 体内必须 await,绝不直接调阻塞库。验证时跑混合负载测试:平凡端点保持亚毫秒延迟,重端点在自己的有界池里饱和而不外溢。单纯调大线程池限额会让线程与内存成倍增长且与 GIL 打架——先修工作安置,再谈限额。
代码示例
GET /health p99 during idle: 2 ms
GET /health p99 while /heavy-sync: 4200 ms
import anyio
anyio.to_thread.current_default_thread_limiter().limit # 40
@app.get("/orders") # sync def: threadpool, fine for moderate blocking IO
def list_orders():
return legacy_client.fetch_orders()
import anyio.to_thread
anyio.to_thread.current_default_thread_limiter().limit = 100
from concurrent.futures import ProcessPoolExecutor
pool = ProcessPoolExecutor(max_workers=4)
@app.get("/report")
async def report():
result = await loop.run_in_executor(pool, cpu_bound_build, payload)
return result