cat blog/.md
Blocking event loop trong FastAPI: những lỗi async tưởng nhỏ nhưng giết throughput
Có một câu hỏi mình hay được hỏi khi review code FastAPI: “Endpoint của em đã async def rồi mà sao benchmark vẫn chỉ ra ~50 req/s, trong khi đồng nghiệp viết Flask sync chạy được hơn?”. Mình mở code xem một lúc thì gần như luôn thấy cùng một thứ: bên trong cái async def đó có một dòng code đồng bộ đang chặn cả event loop.
Async không phải là phép màu. Nó chỉ giúp khi bạn await đúng chỗ. Một dòng time.sleep, một call requests.get, một bcrypt.hashpw lén lút thôi cũng đủ để biến server async của bạn thành một server xử lý một request tại một thời điểm. Bài này gom lại những kiểu blocking phổ biến nhất mình gặp, cách phát hiện, và cách sửa cho đúng.
Tại sao chặn event loop lại tệ đến vậy
FastAPI (và mọi framework async Python) chạy trên một event loop đơn luồng cho mỗi worker process. Khi một request hit endpoint, event loop chạy code đến chỗ await đầu tiên — lúc đó nó thả request đó ra để xử lý request khác. Đến khi I/O xong, nó quay lại tiếp tục.
Cái này nghĩa là: nếu trong endpoint async bạn không bao giờ await, hoặc bạn gọi một hàm sync mà hàm đó tốn 200ms CPU/I/O, thì cả event loop đứng yên 200ms. Mọi request khác đang chờ xử lý cũng đứng yên theo. Throughput tối đa lúc đó = 1 / 0.2 = 5 req/s. Đúng nghĩa đen.
Ngược lại Flask sync với gunicorn 8 worker × 4 thread = 32 request song song được. Bạn viết async, bạn tự đặt bẫy cho mình.
Bốn kiểu blocking thường gặp nhất
1. Gọi HTTP client sync trong endpoint async
import requests
@app.get("/profile/{user_id}")
async def get_profile(user_id: int):
# SAI: requests là sync, block event loop suốt thời gian I/O
r = requests.get(f"http://identity/users/{user_id}", timeout=5)
return r.json()
Một call sang service nội bộ trung bình mất 50ms. Trong 50ms đó event loop chết cứng. Server 1 worker → tối đa 20 req/s, dù máy bạn 16 core.
Sửa: dùng httpx.AsyncClient.
import httpx
client = httpx.AsyncClient(timeout=5)
@app.get("/profile/{user_id}")
async def get_profile(user_id: int):
r = await client.get(f"http://identity/users/{user_id}")
return r.json()
Lưu ý: tạo AsyncClient ở scope module (hoặc qua lifespan) để reuse connection pool. Tạo mới mỗi request là một dạng anti-pattern khác — TCP handshake mỗi lần.
2. Driver database đồng bộ
Đây là lỗi mình thấy nhiều nhất ở team mới chuyển từ Flask sang FastAPI: copy nguyên SQLAlchemy sync sang.
from sqlalchemy.orm import Session
@app.get("/orders/{order_id}")
async def get_order(order_id: int, db: Session = Depends(get_db)):
# SAI: db.execute là sync, block event loop trong khi đợi Postgres
order = db.execute(select(Order).where(Order.id == order_id)).scalar_one()
return order
Trông nó “có vẻ chạy” vì FastAPI cho phép trộn sync và async, nhưng mọi DB call sẽ chặn event loop. Một query 30ms × 100 request đang chờ = request cuối đợi 3 giây mới được xử lý.
Sửa: dùng AsyncSession của SQLAlchemy 2.0 với driver async (asyncpg, aiomysql).
from sqlalchemy.ext.asyncio import AsyncSession
@app.get("/orders/{order_id}")
async def get_order(order_id: int, db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Order).where(Order.id == order_id))
return result.scalar_one()
Nếu vì lý do nào đó phải dùng driver sync (psycopg2, một số legacy ORM), thì wrap qua run_in_executor — sẽ nói ở phần sau.
3. CPU-bound: hashing, parse JSON khổng lồ, image processing
Đây là dạng tricky vì nó không phải I/O nên await không giúp gì.
import bcrypt
@app.post("/register")
async def register(payload: RegisterIn, db: AsyncSession = Depends(get_db)):
# SAI: bcrypt với cost factor 12 mất ~300ms CPU. Block event loop 300ms.
hashed = bcrypt.hashpw(payload.password.encode(), bcrypt.gensalt(12))
...
Tương tự: Image.open(...).resize(...), json.loads một file 50MB, hay parse PDF. Tất cả đều ăn CPU đồng bộ.
Sửa: đẩy ra thread pool executor. FastAPI có sẵn run_in_threadpool:
from fastapi.concurrency import run_in_threadpool
import bcrypt
@app.post("/register")
async def register(payload: RegisterIn, db: AsyncSession = Depends(get_db)):
hashed = await run_in_threadpool(
bcrypt.hashpw, payload.password.encode(), bcrypt.gensalt(12)
)
...
Hoặc asyncio.to_thread (Python 3.9+) cho code thuần asyncio:
hashed = await asyncio.to_thread(bcrypt.hashpw, password.encode(), bcrypt.gensalt(12))
Cảnh báo: thread pool default của FastAPI là 40 thread. Nếu CPU-bound thực sự (không phải I/O wrap sync), nhiều thread không giúp — GIL vẫn chỉ cho phép 1 thread Python chạy tại một thời điểm. Lúc đó cần ProcessPoolExecutor hoặc worker riêng (Celery, RQ).
4. Mix time.sleep với asyncio.sleep
Cái này nghe ngớ ngẩn nhưng mình thấy trong code production thật, thường ở chỗ retry logic:
async def fetch_with_retry(url: str):
for i in range(3):
try:
return await client.get(url)
except httpx.RequestError:
time.sleep(2 ** i) # SAI: block event loop 1s, 2s, 4s
Trong 2 giây đó event loop chết. Mọi request khác cũng chờ.
Sửa: await asyncio.sleep(2 ** i).
Cách phát hiện: bật chế độ debug event loop
Python asyncio có sẵn detector cho coroutine chạy lâu. Bật bằng env var hoặc code:
PYTHONASYNCIODEBUG=1 uvicorn main:app
Hoặc:
import asyncio
loop = asyncio.get_event_loop()
loop.set_debug(True)
loop.slow_callback_duration = 0.1 # warn nếu callback > 100ms
Khi có chỗ block, log sẽ in Executing <Handle ...> took 0.523 seconds kèm stack trace. Cực hữu ích để soi.
Production thì bật slow_callback_duration cao hơn (vd 0.5s) và push log vào hệ thống tập trung. Mỗi cảnh báo là một ứng cử viên cần điều tra.
Đo đạc thực tế
Lý thuyết hay, nhưng phải đo mới tin. Mình hay viết một endpoint test đơn giản để chứng minh cho team thấy:
import asyncio, time, httpx
from fastapi import FastAPI
app = FastAPI()
@app.get("/sync-sleep")
async def sync_sleep():
time.sleep(0.5) # blocking
return {"ok": True}
@app.get("/async-sleep")
async def async_sleep():
await asyncio.sleep(0.5) # non-blocking
return {"ok": True}
Chạy benchmark với wrk -t4 -c50 -d10s:
/sync-sleep → 2 req/s, latency p99 ~25s
/async-sleep → 99 req/s, latency p99 ~510ms
Cùng một endpoint logic giống hệt, chỉ khác mỗi time.sleep vs asyncio.sleep, throughput chênh gần 50 lần. Đây là con số đủ để thuyết phục mọi PR reviewer.
Khi nào nên dùng def thường thay vì async def
Mẹo ít người biết: nếu endpoint của bạn toàn bộ là code sync (vì lý do nào đó không async được), hãy khai báo nó là def thường, đừng cố nhồi vào async def.
# Tốt hơn: FastAPI tự chạy nó trong thread pool, không block event loop
@app.post("/legacy/upload")
def upload_legacy(file: UploadFile = File(...)):
process_with_legacy_sync_library(file.file)
return {"ok": True}
FastAPI có hành vi đặc biệt: hàm endpoint def thường sẽ được tự động chạy qua run_in_threadpool, không chặn event loop. Còn async def thì FastAPI cho rằng bạn biết mình đang làm gì và để bạn tự xử.
Quy tắc:
- Code toàn
awaitđược →async def - Code toàn sync, không có gì để await →
def(an toàn hơn) - Code mix:
async def+run_in_threadpoolcho phần sync
Tệ nhất là async def rồi gọi sync trực tiếp — đây là cái bẫy gặp nhiều nhất.
Checklist khi review PR FastAPI
Mỗi lần mình review code FastAPI, đây là những thứ mình tìm bằng grep:
# Sync HTTP client trong code async
grep -rn "requests\." app/ --include="*.py" | grep -v "test_"
# Driver DB sync
grep -rn "from sqlalchemy.orm import Session" app/
# time.sleep trong async function
grep -B2 -A2 "time.sleep" app/
# bcrypt/hashlib trực tiếp không qua executor
grep -rn "bcrypt.hashpw\|bcrypt.checkpw" app/
# json.loads/dumps trên data lớn không có streaming
grep -rn "json.loads\|json.dumps" app/
Không phải mọi match đều sai — nhưng đáng để kiểm tra. Code reviewer cẩn thận hơn benchmark sau khi đã lên prod.
Tổng kết
Async Python không khó, nhưng nó không tha thứ. Một dòng sync giấu trong middleware, dependency, hay hàm utility cũ là đủ để biến server async của bạn thành phiên bản tệ hơn server sync. Worker process chỉ có một event loop. Mọi blocking call đều ăn vào pie chung đó.
Quy tắc mình rút ra sau vài lần debug production:
- Audit từng import. Mỗi thư viện có version async chưa? Nếu chưa, có wrap qua executor được không?
- Bật asyncio debug ở dev và staging. Slow callback log là bạn.
- Benchmark có chủ đích: so sánh throughput trước và sau một thay đổi, đừng tin cảm giác.
- Khi nghi ngờ, để FastAPI tự lo: dùng
defthường cho code sync, để framework tự push qua thread pool.
Async đẹp khi bạn dùng đúng. Còn dùng sai, nó im lặng làm bạn tin server đang chạy tốt — cho đến lúc traffic thật đến và mọi thứ vỡ.
cat comments.log