Blocking event loop trong FastAPI: những lỗi async tưởng nhỏ nhưng giết throughput — hoatq.dev

cat blog/.md

Blocking event loop trong FastAPI: những lỗi async tưởng nhỏ nhưng giết throughput

date: tags: python, fastapi, async, performance, backend

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_threadpool cho 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:

  1. 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?
  2. Bật asyncio debug ở dev và staging. Slow callback log là bạn.
  3. Benchmark có chủ đích: so sánh throughput trước và sau một thay đổi, đừng tin cảm giác.
  4. Khi nghi ngờ, để FastAPI tự lo: dùng def thườ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ỡ.

// reactions


cat comments.log


hoatq@dev : ~/blog $