一句話先說結論:FastAPI 裡
async def端點是直接跑在事件循環主執行緒上的,它預設你是“非阻塞”的。只要在裡面調了time.sleep、requests.get、同步數據庫驅動這些阻塞程式碼,整個事件循環就被卡住——不是這一個接口慢,而是所有接口(包括返回常量的/health)一起卡。解法是換異步庫,或者用run_in_threadpool/asyncio.to_thread把同步調用丟到執行緒池。
背景
FastAPI 跑在 Uvicorn 之上,Uvicorn 用 Python 的 asyncio 事件循環來調度所有請求。事件循環是單執行緒的協作式調度:一個協程只有在碰到 await 時才讓出控制權,輪不到其他協程跑。這套模型在“等 I/O”時幾乎零開銷,所以能支撐很高併發——前提是每個人都按時讓路。
現象
一個很反直覺的線上事故特徵:CPU 明明很低(個位數),數據庫也正常,但所有接口的 p99 一起漲,連返回一個常量、本應微秒級響應的 /health 都要等幾百毫秒。
純阻塞卡死通常不拋例外、只在延遲上體現。但同一個“沒把事件循環當回事”的毛病還有另一副面孔:在已經跑起來的 loop 裡再 run_until_complete 或 asyncio.run,會直接炸運行時例外。報錯原文:
RuntimeError: This event loop is already running
完整堆疊:
Traceback (most recent call last):
File "/app/api/data.py", line 21, in get_data
loop.run_until_complete(slow_request())
File "/usr/local/lib/python3.10/asyncio/base_events.py", line 625, in run_until_complete
self._check_running()
File "/usr/local/lib/python3.10/asyncio/base_events.py", line 587, in _check_running
raise RuntimeError('This event loop is already running')
RuntimeError: This event loop is already running
追根溯源通常是這樣的:有人把一堆 def 端點“清理”成了 async def(以為 async 就是快),裡面卻還調著 requests.get 或同步 ORM。程式碼照跑、測試照過、staging 延遲不變,上線後流量一上來就崩。而回滾那個“只是加了 async 關鍵字”的 PR 就好了——這恰恰是最迷惑的地方。
根因分析
關鍵是理解 FastAPI 怎麼分發端點:一個 async def 端點在事件循環主執行緒上執行;一個普通 def 端點則被 Starlette 丟到工作執行緒池(預設 40 執行緒),在 loop 裡 await 它。
所以同一個阻塞調用,放在三種寫法裡結果天差地別:
| 端點寫法 | 內部調用的程式碼 | 高負載下的表現 |
|---|---|---|
def |
requests.get(...) |
佔用 40 個執行緒池執行緒裡的 1 個,loop 不受影響 |
async def |
await client.get(...) (httpx) |
loop 正確掛起協程,乾淨擴展 |
async def |
requests.get(...) |
整個進程停滯,直到調用返回 |
第三種是三者裡最糟的。因為在 async def 裡寫阻塞調用,等於把執行從“受保護的執行緒池”挪到了“最不能阻塞的主執行緒”——async def 這個關鍵字本身,就是把你從安全的執行緒池裡踢出來,再塞進那個不能停的執行緒上。
事件循環沒有搶佔:一個協程在 await 之間如果調了 time.sleep 或同步網絡調用,它不會讓出控制權,而是把唯一的執行緒扣住不還。這 800ms 裡,進程裡沒有別的請求能推進、定時器不觸發,/health 也得排在後面等。
解決方案
方案 A(正確姿勢):換真正的異步庫
讓 promise 端到端成立——async 端點就該用異步庫:
import httpx # 替代 requests
import asyncio
@app.get("/data")
async def get_data():
async with httpx.AsyncClient() as client:
r = await client.get("https://api.example.com/data")
return r.json()
數據庫用 asyncpg(或 async SQLAlchemy)、文件用 aiofiles、sleep 用 await asyncio.sleep()。這才是 async 收益真正的來源。
方案 B(兜底):run_in_threadpool / asyncio.to_thread
必須用同步庫(老 SDK、同步 ORM、time.sleep、讀大文件)時,把它丟進執行緒池:
from fastapi.concurrency import run_in_threadpool
import time
def slow_sync_task():
time.sleep(3) # 阻塞操作,但会在别的线程跑
return "done"
@app.get("/work")
async def work():
result = await run_in_threadpool(slow_sync_task)
return {"result": result}
Python 3.9+ 也可以直接:
import asyncio
result = await asyncio.to_thread(slow_sync_task)
效果立竿見影:原來兩個請求串行要 6 秒,改成執行緒池後兩個請求並行、總耗時壓回 3 秒,loop 全程不卡。
需注意:執行緒池是有限的(預設 40),別把長事務、超長循環、海量併發同步調用一股腦塞進去,否則池子被佔滿、後面的請求排隊。
方案 C:CPU 密集改用進程池
如果是矩陣運算、模型推理這類 CPU 密集(不是 I/O 等),執行緒池也不解決問題(GIL 限制),要用 ProcessPoolExecutor:
from concurrent.futures import ProcessPoolExecutor
executor = ProcessPoolExecutor(max_workers=2)
loop = asyncio.get_running_loop()
prediction = await loop.run_in_executor(executor, blocking_model_predict, text)
排查小抄
從面板就能認出 loop 阻塞:所有路由一起退化、退化幅度跟著某個具體路由的流量走、CPU 全程很低、延遲的“地板”整體抬升而不是隻有長尾變長。要坐實的話:
# 抓当前线程栈,阻塞时能看到 socket.recv / requests.get 卡在端点函数里
py-spy dump --pid <uvicorn worker pid>
也可以 PYTHONASYNCIODEBUG=1 啟動,asyncio 會打印每個卡住 loop 超過 100ms 的回調(Executing took 0.874 seconds),把無形的停頓變成日誌裡一個具名函數。
小結
async def是“我不會阻塞”的承諾,不是裝飾;做不到就老老實實用def或執行緒池。- 記住三檔:
def+ 同步庫「能跑但不高併發」,async def+ 異步庫「最優」,async def+ 同步庫「最差」。 - I/O 阻塞用
run_in_threadpool/asyncio.to_thread,CPU 密集用ProcessPoolExecutor。 - 判斷口訣:CPU 低 + 延遲高 + 全端點一起慢,先懷疑事件循環被阻塞。
