+4

[FastAPI] Tránh block event loop: Hướng dẫn async đúng cách

Trong bài viết này, mình sẽ chia sẻ lại quá trình tìm hiểu của bản thân về cách hoạt động của async/await trong FastAPI, từ những ví dụ cơ bản đến cách mà các tác vụ bất đồng bộ vận hành bên dưới thông qua event loop. Mình tin rằng đây là chủ đề dễ gây nhầm lẫn khi mới làm việc với Python async hoặc FastAPI.

Note: Bài viết được biên soạn dựa trên quá trình tìm hiểu cá nhân, có sử dụng AI (ChatGPT) để hỗ trợ giải thích và sắp xếp nội dung cho dễ hiểu hơn.


1. Use case

Giả sử bạn có một API FastAPI như sau:

from fastapi import FastAPI
import time
import asyncio

app = FastAPI()

@app.get("/bad_async")
async def bad_async():
    time.sleep(5)  
    return {"msg": "bad async"}

@app.get("/good_async")
async def slow_non_blocking():
    await asyncio.sleep(5)  
    return {"msg": "done"}

@app.get("/quick")
async def quick():
    return {"msg": "quick response"}

Nếu bạn gọi API /bad_async trước, rồi ngay sau đó gọi tiếp /quick (có thể thực hiện đơn giản bằng cách mở 2 tab trên trình duyệt hoặc call 2 tab POSTMAN), thì /quick sẽ không lập tức phản hồi ngay, thay vào đó, nó sẽ phải chờ sau hơn 5 giây, sau khi API /bad_async được xử lý xong.

Điều này sẽ là một hạn chế rất lớn, gây nghẽn service. Để giải quyết vấn đề này, ta đơn giải thay thế /bad_async bằng API good_async. Khi đó, nếu gọi /good_async rồi gọi đồng thời /quick, thì /quick phản hồi ngay lập tức. Tại sao lại có sự khác biệt như vậy?

Câu trả lời nằm ở cơ chế hoạt động bên trong Event loop của asyncio trong Python


2. Event loop

Event loop là một thành phần cốt lõi của mô hình lập trình bất đồng bộ trong Python, được cung cấp bởi thư viện asyncio. Nó là một cơ chế điều phối (scheduler) chịu trách nhiệm quản lý và thực thi các coroutine, future, task và I/O bất đồng bộ.

Hãy tưởng tượng bạn đang ở trong một căn bếp đông khách, nơi chỉ có một đầu bếp duy nhất nhưng phải xử lý nhiều món ăn khác nhau — đó chính là event loop trong Python.

Mỗi món ăn ở đây là một tác vụ bất đồng bộ (asynchronous task), ví dụ như:

  • Luộc mì (mất 5 phút).
  • Hầm súp (mất 10 phút).
  • Làm salad (chỉ mất 2 phút).

Trong mô hình truyền thống (blocking), đầu bếp sẽ:

  1. Đứng chờ 5 phút cho nước sôi và luộc mì xong.
  2. Sau đó mới bắt đầu hầm súp (chờ thêm 10 phút).
  3. Rồi mới làm salad.

⏱️ Tổng thời gian phục vụ: 17 phút


Nhưng trong mô hình event loop, đầu bếp thông minh hơn:

  1. Bắt đầu luộc mì → Đặt lên bếp và đợi nước sôi (await).
  2. Trong lúc chờ nước sôi, quay sang hầm súp → Đặt nồi lên và chờ nước nóng (await).
  3. Trong lúc cả hai đang hầm và luộc, tranh thủ làm salad ngay lập tức.
  4. Khi nước sôi xong, quay lại tiếp tục món mì.
  5. Khi súp hầm xong, quay lại nêm nếm và hoàn tất.

⏱️ Tổng thời gian phục vụ: khoảng 10 phút hoặc ít hơn, vì không có thời gian chết.
Mọi thứ được xen kẽ thông minh, không món nào bị quên, và không cần nhiều đầu bếp!


Quay trở lại ví dụ code minh họa

  • Khi thực hiện await asyncio.sleep(5) (non-blocking) trong API /good_async, thư viện asyncio khiến mọi thứ được hoạt động giống như một đầu bếp "thông minh", sẽ tranh thủ thời gian chờ nước sôi để thực hiện công việc khác (gọi /quick)

  • Ngược lại, nếu chỉ sử dụng time.sleep(5) (blocking), giống như người đầu bếp sẽ chỉ đứng và chờ nước sôi, dù không cần làm thêm gì cả. Ngay sau khi việc hoàn tất, nước đã sôi, anh ta mới có thể đi làm việc khác (quick mới được sử lý)

Có thể hình dung event loop giống như bộ điều phối trung tâm trong mô hình bất đồng bộ. Trong lập trình đồng bộ thông thường, các tác vụ sẽ được thực hiện tuần tự — tác vụ sau phải đợi tác vụ trước hoàn thành mới được chạy tiếp.

Tuy nhiên, khi sử dụng cơ chế bất đồng bộ, nếu một tác vụ đang rơi vào trạng thái chờ đợi (chẳng hạn như sleep, đọc file, gọi đến một service bên ngoài), event loop sẽ tạm "gác" lại tác vụ đó và chuyển sang xử lý các tác vụ khác đang sẵn sàng. Sau khi tác vụ ban đầu sẵn sàng tiếp tục, event loop sẽ quay lại hoàn tất phần còn lại của nó. Nhờ vậy, ứng dụng có thể xử lý nhiều việc cùng lúc một cách hiệu quả.


🔄 Khi một async task nhường quyền điều khiển, ai sẽ thông báo cho event loop biết để quay lại xử lý?

Khi một tác vụ bất đồng bộ bị await, nó sẽ tạm thời bị "đóng băng", và event loop sẽ chuyển sang xử lý các tác vụ khác.

Ví dụ:

await asyncio.sleep(5)

Sau khi việc chờ đợi kết thúc, event loop sẽ được đánh thức, và nó sẽ đưa tác vụ bị đóng băng quay lại hàng đợi để tiếp tục thực thi phần còn lại.


📁 Còn với việc đọc/ghi file hoặc gửi HTTP request thì sao?

Để xử lý các thao tác I/O một cách bất đồng bộ như đọc file hoặc gửi HTTP request, ta thường dùng:

  • aiofiles thay vì open
  • httpx.AsyncClient thay vì requests

Các thao tác này đều là I/O-bound và bất đồng bộ. Khi bạn await chúng, event loop sẽ tạm dừng task hiện tại, rồi tiếp tục xử lý các task khác trong khi đợi kết quả trả về.

🧠 Vậy ai "theo dõi" để báo cho event loop biết khi nào task đã hoàn thành?

Câu trả lời là:
📡 Hệ điều hành + Hệ thống theo dõi sự kiện I/O (epoll, IOCP) + Event loop runtime như asyncio hoặc uvloop

  • Ở tầng thấp, Python sử dụng selectors (trên Linux là epoll, trên Windows là IOCP) để đăng ký và theo dõi socket hoặc file descriptor.
  • Khi một socket/file sẵn sàng để đọc/ghi, OS sẽ báo lại.
  • Event loop (asyncio, uvloop) nhận tín hiệu này và đưa task tương ứng trở lại hàng đợi để tiếp tục thực thi.

3. ThreadPoolExecutor trong FastAPI

Khi viết các API sử dụng FastAPI, liệu có sự khác biệt gì giữa các hàm dưới các @decorator sử dụng async và không sử dụng nó? Ví dụ, nếu bạn bỏ đi từ khóa async và sửa lại đoạn code:

@app.get("/bad_async")
async def bad_async():
    time.sleep(5)
    return {"msg": "bad async"}

trở thành:

@app.get("/bad_async")
def bad_async():
    time.sleep(5)
    return {"msg": "bad async"}

Thì khi chạy lại thử nghiệm trong phần đầu, điều bất ngờ là API /quick lại phản hồi ngay lập tức sau khi gọi /bad_async, mặc dù chúng ta đang sử dụng đoạn code time.sleep(5)!


Tại sao lại có sự khác biệt này trong FastAPI?

Khi bạn dùng def, FastAPI hiểu rằng đây là một tác vụ đồng bộ → nó không chạy trong event loop chính, mà được đưa sang một thread phụ, do ThreadPoolExecutor (tích hợp sẵn trong FastAPI/Uvicorn) quản lý.

Điều này có nghĩa là:

  • time.sleep(5) được thực thi trong thread phụ, không làm block event loop.
  • Các request khác như /quick vẫn được event loop chính xử lý bình thường, không bị chậm.

⚠️ Nhưng cần cẩn thận khi lạm dụng def!

Mặc dù việc đẩy các hàm đồng bộ sang thread phụ giúp "tránh" block event loop, nhưng:

  • ThreadPoolExecutor có giới hạn số lượng thread (mặc định thường chỉ ~10–20).
  • Nếu có quá nhiều request đồng bộ cùng lúc → các thread bị chiếm hết → các request mới sẽ phải xếp hàng chờ, gây ra tắc nghẽn.

Hy vọng bài viết này giúp bạn hiểu rõ hơn về async/await và event loop trong Python/FastAPI. Chúc bạn code hiệu quả hơn và tránh được những bug khó chịu do block event loop!


📚 Tài liệu tham khảo tổng hợp


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí