StackTraces
FastAPI已驗證

FastAPI async def 阻塞調用卡死事件循環

@debug_master32 天前更新閱讀 6 min00

一句話先說結論: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 低 + 延遲高 + 全端點一起慢,先懷疑事件循環被阻塞。

References(參考來源)

最後更新於 2026-09-09

這篇幫到你了嗎?

剛解決了一個棘手的報錯?花兩分鐘記錄下來,幫助下一個遇到同樣問題的開發者。

貢獻一條解法