Tất cả bài viết

Python chạy đồng thời thế nào: từ process, thread, coroutine xuống tận kernel, rồi lên FastAPI

Process, thread, GIL, coroutine, event loop, epoll và FastAPI: mỗi tầng làm gì khi một server Python giữ hàng nghìn request cùng lúc, kèm thí nghiệm chạy thật.

Trong bài First-principles thinking, có một câu nghe rất gọn: "một process FastAPI giữ được hàng chục nghìn request đang chờ, mà không cần hàng chục nghìn thread". Bài này mổ xẻ câu đó, đi từ trên xuống dưới: process, thread và coroutine khác nhau ra sao, GIL là gì, await thật ra làm gì, ai đánh thức một coroutine đang ngủ, rồi quay lại FastAPI để thấy mọi thứ ghép lại thế nào.

Mọi con số trong bài đều đo trên máy thật: Linux (WSL2), 12 nhân, Python 3.12 và bản Python 3.14 không có GIL.

Ba cách làm nhiều việc cùng lúc

Trước hết, hai từ hay bị dùng lẫn:

  • Đồng thời (concurrency): nhiều việc đang dở dang cùng lúc. Một người nấu ba nồi: đặt nồi này lên bếp, trong lúc chờ sôi thì thái rau cho nồi kia.
  • Song song (parallelism): nhiều việc thật sự chạy cùng một thời điểm. Ba người nấu ba nồi.

Server web cần cả hai: đồng thời để giữ nhiều request đang chờ, song song để tận dụng nhiều nhân CPU. Python có ba công cụ:

Process Thread Coroutine
Ai lập lịch Hệ điều hành Hệ điều hành Event loop (code Python)
Bộ nhớ Riêng hoàn toàn Chung trong process Chung trong thread
Chạy song song trên nhiều nhân Có Với Python mặc định thì không (GIL) Không
Chi phí mỗi cái Cả một trình thông dịch Python Một stack riêng, thường 8 MB bộ nhớ ảo trên Linux Vài KB
Chuyển qua lại Kernel làm, tốn kém nhất Kernel làm Một lời gọi hàm trong Python

Điểm mấu chốt: hệ điều hành chỉ biết process và thread. Coroutine là khái niệm của riêng Python; kernel không hề biết nó tồn tại.

CPython và GIL

CPython, bản Python phổ biến nhất, dịch code thành bytecode rồi chạy từng lệnh bytecode trong một vòng lặp viết bằng C. Mỗi object Python có một bộ đếm tham chiếu (reference count) để biết khi nào được giải phóng. Nếu hai thread cùng sửa bộ đếm đó một lúc, nó có thể sai và gây lỗi bộ nhớ. Thay vì khóa từng object, CPython dùng một khóa chung cho cả trình thông dịch: GIL (Global Interpreter Lock). Thread nào giữ GIL thì mới được chạy bytecode.

GIL được nhả ra trong ba trường hợp:

  • Khi thread gọi một thao tác I/O có thể phải chờ, như đọc socket, đọc file, time.sleep.
  • Khi thư viện viết bằng C chủ động nhả nó lúc tính toán, như numpy, Pillow, hashlib.
  • Khi một thread đã giữ GIL quá 5 ms mà có thread khác đang chờ: sys.getswitchinterval() trả về 0.005.

Hệ quả: thread giúp được khi chờ I/O, nhưng không giúp gì khi dùng CPU bằng code Python. Đo thử với một việc thuần CPU, chạy 4 lần:

import time
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor


def count_down(n: int) -> None:
    while n:
        n -= 1


N = 20_000_000

if __name__ == "__main__":
    start = time.perf_counter()
    for _ in range(4):
        count_down(N)
    print(f"one after another : {time.perf_counter() - start:.2f} s")

    for name, pool in (("4 threads", ThreadPoolExecutor), ("4 processes", ProcessPoolExecutor)):
        start = time.perf_counter()
        with pool(max_workers=4) as executor:
            list(executor.map(count_down, [N] * 4))
        print(f"{name:<18}: {time.perf_counter() - start:.2f} s")
Cách chạy Python 3.12 (có GIL) Python 3.14t (không GIL)
Lần lượt 1,39 s 1,04–1,16 s
4 thread 1,39–1,46 s 0,32–0,36 s
4 process 0,43–0,49 s 0,38–0,49 s

Với GIL, 4 thread không nhanh hơn chạy lần lượt chút nào; chỉ 4 process mới dùng được 4 nhân. Bản không GIL (hậu tố t, được hỗ trợ chính thức từ Python 3.14 nhưng phải tự chọn dùng) cho thread chạy song song thật. Process vẫn tốn thêm thời gian khởi động một trình thông dịch mới cho mỗi worker, nên không nhanh gấp 4 lần.

Kết luận cho bản Python mặc định: thread cho việc chờ I/O, process cho việc dùng CPU. Còn coroutine là cách thứ ba để chờ I/O, rẻ hơn thread nhiều.

Coroutine: một hàm biết tạm dừng

Gọi một hàm async def không chạy gì cả; nó chỉ tạo ra một object coroutine. Object này giống một hàm có thể dừng giữa chừng, nhớ chỗ đang dừng, và chạy tiếp khi ai đó bảo. Thử tự tay "lái" một coroutine, không cần asyncio:

import types


@types.coroutine
def wait_for(what):
    """Pause the coroutine and hand `what` to whoever is driving it."""
    reply = yield what
    return reply


async def handler():
    print("handler: asking for the user")
    user = await wait_for("SELECT name FROM users WHERE id = 1")
    print("handler: got", user)
    return f"<h1>Hello {user}</h1>"


coro = handler()  # nothing runs yet: calling an async function only creates the coroutine
request = coro.send(None)  # run until the first pause
print("driver: coroutine is paused, waiting for:", request)
try:
    coro.send("An")  # resume it with the answer
except StopIteration as done:
    print("driver: finished with", done.value)
handler: asking for the user
driver: coroutine is paused, waiting for: SELECT name FROM users WHERE id = 1
handler: got An
driver: finished with <h1>Hello An</h1>

await không tự chờ gì cả. Nó chuyền yêu cầu ra ngoài, qua từng tầng await, tới người đang lái coroutine bằng send(). Người đó quyết định khi nào cho chạy tiếp và trả lời bằng gì. Trong asyncio, người lái là event loop, còn thứ được chuyền ra là một Future: lời hứa "khi có kết quả sẽ báo".

Event loop: tự viết một cái trong 40 dòng

Event loop chỉ làm hai việc lặp đi lặp lại: chạy các coroutine đang sẵn sàng cho tới khi chúng tạm dừng, rồi hỏi hệ điều hành "trong các socket này, cái nào có dữ liệu rồi?". Đây là một bản đồ chơi, đủ để chạy thật:

import selectors
import socket
import threading
import time
import types
from collections import deque

selector = selectors.DefaultSelector()  # EpollSelector on Linux
ready = deque()  # coroutines that can run right now


@types.coroutine
def readable(sock):
    yield sock  # pause, telling the loop which socket we're waiting on


def run(*coroutines):
    ready.extend(coroutines)
    while ready or selector.get_map():
        while ready:
            coro = ready.popleft()
            try:
                sock = coro.send(None)  # run it until its next pause
                selector.register(sock, selectors.EVENT_READ, coro)
            except StopIteration:
                pass  # this coroutine is done
        if selector.get_map():
            for key, _ in selector.select():  # epoll_wait: the thread sleeps in the kernel here
                selector.unregister(key.fileobj)
                ready.append(key.data)  # its socket has data: that coroutine can go on


async def fetch(name, delay):
    ours, server = socket.socketpair()  # stands in for a connection to a slow server
    threading.Timer(delay, server.send, [f"reply after {delay} s".encode()]).start()
    await readable(ours)
    print(f"{name}: {ours.recv(100).decode()}")


start = time.perf_counter()
run(fetch("A", 1.0), fetch("B", 1.0), fetch("C", 0.5))
print(f"total: {time.perf_counter() - start:.2f} s")
C: reply after 0.5 s
A: reply after 1.0 s
B: reply after 1.0 s
total: 1.00 s

Ba việc chờ 1 s, 1 s và 0,5 s mà tổng chỉ mất 1 s, trên một thread duy nhất. Dòng quan trọng nhất là selector.select(): trên Linux, đó là system call epoll_wait. asyncio thật làm đúng những việc này, cộng thêm timer, hủy task, xử lý lỗi và nhiều tối ưu khác.

Xuống tận kernel: ai đánh thức coroutine?

Theo dõi các system call của một coroutine thật, kết nối tới một server cố tình trả lời chậm 1 giây:

import asyncio


async def fetch():
    reader, writer = await asyncio.open_connection("127.0.0.1", 9099)
    print("waiting...", flush=True)
    data = await reader.read(100)  # the coroutine suspends here
    print("woke up with", data, flush=True)


asyncio.run(fetch())

Chạy bằng strace (đã lược bớt, cột đầu là thời điểm):

07.756  epoll_create1(EPOLL_CLOEXEC) = 3                      the loop creates an epoll instance
07.760  connect(6, ...:9099) = -1 EINPROGRESS                 non-blocking: returns at once
07.760  epoll_ctl(3, EPOLL_CTL_ADD, 6, EPOLLOUT)              "tell me when connected"
07.762  epoll_ctl(3, EPOLL_CTL_ADD, 6, EPOLLIN)               await reader.read(): "tell me when there's data"
07.763  write(1, "waiting...")
07.763  epoll_wait(3, [...], 2, -1)                           the thread sleeps in the kernel...
08.762  recvfrom(6, "hello") = 5                              ...and runs again exactly 1 s later
08.762  write(1, "woke up with b'hello'")

Trong lúc chờ, /proc/<pid>/stat cho thấy process ở trạng thái S (sleeping), và cả chương trình chỉ tốn 3 tick CPU. Thread không hỏi đi hỏi lại "có dữ liệu chưa"; nó nằm yên trong kernel. Từng bước diễn ra như sau:

  1. Coroutine tạm dừng (Python). Socket ở chế độ non-blocking (O_NONBLOCK), nên khi chưa có dữ liệu, kernel trả lỗi EAGAIN ngay thay vì bắt thread chờ. asyncio đăng ký "báo tôi khi fd 6 đọc được" bằng epoll_ctl, tạo một Future, và coroutine chuyền Future đó ra ngoài rồi dừng lại. Lúc này nó chỉ là một object trong bộ nhớ.
  2. Event loop đi ngủ (system call). Hết việc sẵn sàng, loop gọi epoll_wait, với thời gian chờ tối đa bằng khoảng cách tới timer gần nhất, hoặc -1 là chờ mãi. Kernel cho thread ngủ trên hàng đợi của epoll; bộ lập lịch không chạy nó nữa.
  3. Dữ liệu tới (kernel). Card mạng ghi gói tin vào RAM bằng DMA rồi phát một ngắt phần cứng. Kernel xử lý gói qua IP, TCP, chép dữ liệu vào bộ đệm của socket, rồi socket báo "có dữ liệu". Callback mà epoll gắn sẵn vào socket đưa fd 6 vào danh sách sẵn sàng và đánh thức thread đang ngủ.
  4. Coroutine chạy tiếp (lại ở Python). epoll_wait trả về [fd 6: EPOLLIN]. Loop gọi recvfrom, lần này có dữ liệu ngay, gán kết quả cho Future, và xếp coroutine vào hàng chờ chạy. Vòng lặp kế tiếp gọi send(), coroutine chạy tiếp từ đúng dòng await.

Vậy: phần cứng → ngắt → kernel → epoll_wait trả về → event loop → coroutine. Kernel đánh thức thread; event loop đánh thức coroutine.

Đây cũng là lý do một thread giữ được hàng chục nghìn kết nối: nó chỉ ngủ ở một chỗ duy nhất, và một lần epoll_wait trả về tất cả socket đã sẵn sàng. Với mô hình thread, mỗi kết nối cần một thread ngủ riêng, mỗi thread một stack riêng.

Ba chi tiết đáng biết:

  • Đánh thức từ thread khác. Trong lần chạy trên còn có fd 4 và 5: một cặp socket asyncio tự tạo. Khi một thread khác cần báo cho loop, qua call_soon_threadsafe, nó ghi 1 byte vào cặp này để epoll_wait trả về.
  • Ổ đĩa không dùng epoll được. epoll không chấp nhận file thường, vì kernel coi file thường luôn sẵn sàng. Vì vậy đọc file "async" trong Python, như thư viện aiofiles, thật ra chạy trong thread pool. Thư viện aiosqlite cũng chạy SQLite trong một thread riêng, rồi báo kết quả về loop bằng chính call_soon_threadsafe. Linux có io_uring cho I/O đĩa bất đồng bộ thật, nhưng asyncio không dùng nó.
  • uvloop. Event loop thay thế, viết bằng Cython trên libuv (thư viện I/O của Node.js). Cơ chế vẫn là socket non-blocking và epoll_wait, chỉ phần chạy trong user space nhanh hơn.

Lên lại FastAPI

Một app FastAPI chạy bằng uvicorn có ba tầng:

  • uvicorn: server ASGI. Nó mở socket, chạy event loop (tự dùng uvloop nếu đã cài), và phân tích HTTP bằng httptools, một bộ phân tích viết bằng C.
  • Starlette: framework ASGI bên dưới FastAPI: routing, request, response.
  • FastAPI: thêm kiểm tra dữ liệu bằng Pydantic, dependency injection, tài liệu OpenAPI.

ASGI là giao ước giữa server và app: app là một hàm async nhận scope (thông tin request), receive và send. Với mỗi request, uvicorn tạo một Task trên event loop để chạy app. Từ đây, cách bạn viết endpoint quyết định nó chạy ở đâu:

  • async def chạy thẳng trên event loop. Mỗi await vào I/O là một lần nhường loop.
  • def thường được FastAPI đẩy sang thread pool bằng run_in_threadpool. Pool này do AnyIO quản lý, mặc định tối đa 40 thread. Dependency viết bằng def cũng vậy.

Thí nghiệm: ba endpoint, mỗi cái chờ 0,1 giây theo một cách khác nhau.

import asyncio
import time

from fastapi import FastAPI

app = FastAPI()


@app.get("/async")
async def non_blocking():
    await asyncio.sleep(0.1)  # waits without holding the thread: the loop serves others
    return {"ok": True}


@app.get("/sync")
def in_thread_pool():
    time.sleep(0.1)  # blocks, but FastAPI runs a plain def in its thread pool
    return {"ok": True}


@app.get("/mistake")
async def blocks_the_loop():
    time.sleep(0.1)  # blocks inside async def: the whole event loop stops
    return {"ok": True}

Bắn 100 request cùng lúc vào mỗi endpoint, bằng một client asyncio tối giản:

import asyncio
import time

N = 100


async def get(path: str) -> None:
    reader, writer = await asyncio.open_connection("127.0.0.1", 8123)
    writer.write(f"GET {path} HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n".encode())
    await writer.drain()
    await reader.read()  # until the server closes the connection
    writer.close()


async def main() -> None:
    for path in ("/async", "/sync", "/mistake"):
        start = time.perf_counter()
        await asyncio.gather(*(get(path) for _ in range(N)))
        print(f"{N} requests at once to {path:<9}: {time.perf_counter() - start:5.2f} s")


asyncio.run(main())
Endpoint 100 request cùng lúc Vì sao
/async 0,11–0,12 s 100 coroutine cùng chờ trên một loop
/sync 0,33–0,35 s 40 thread: 3 đợt × 0,1 s
/mistake 10,08–10,09 s Loop bị chặn: 100 × 0,1 s nối đuôi nhau

/mistake là lỗi hay gặp nhất: time.sleep trong async def không nhường loop, nên trong 0,1 giây đó cả process đứng yên, kể cả các request vào endpoint khác. requests.get(), một driver database đồng bộ, hay một vòng tính toán nặng trong async def đều gây ra đúng hiện tượng này. Lần đầu chạy thử, client của mình còn hết thời gian chờ trước khi /mistake kịp trả lời hết.

Vài quy tắc rút ra:

  • async def chỉ khi mọi I/O bên trong đều await được: driver database async, httpx.AsyncClient, asyncio.sleep.
  • Thư viện đồng bộ thì viết def, để FastAPI đưa nó sang thread pool. Nếu chờ lâu và nhiều, 40 thread sẽ thành giới hạn; có thể nâng bằng anyio.to_thread.current_default_thread_limiter().total_tokens, nhưng chuyển sang thư viện async thường tốt hơn.
  • Việc nặng CPU không chạy trên loop: đẩy sang ProcessPoolExecutor, hoặc một worker riêng nhận việc qua hàng đợi.
  • Một process chỉ dùng được khoảng một nhân. Muốn dùng 4 nhân thì chạy uvicorn --workers 4, hoặc mỗi pod một process rồi tăng số pod. Các process không chia sẻ bộ nhớ, nên mọi trạng thái như phiên đăng nhập phải nằm ở database hay Redis.

Website bạn đang đọc chạy đúng kiểu này: uvicorn với uvloop, endpoint async def, SQLite qua aiosqlite, và một worker, vì với lượng truy cập hiện tại một nhân là đủ.

Tóm lại

Tầng Ai làm Việc chính
Phần cứng Card mạng, ngắt Báo có dữ liệu
Kernel epoll, bộ lập lịch Cho thread ngủ, đánh thức khi socket sẵn sàng
Event loop asyncio / uvloop Gọi epoll_wait, chạy coroutine nào đã sẵn sàng
Coroutine Code async def của bạn Chạy tới await, nhường loop, chạy tiếp khi có dữ liệu
Thread pool AnyIO, 40 thread Chạy code đồng bộ (def) mà không chặn loop
Process uvicorn workers, pod Dùng nhiều nhân CPU

Hiểu từng tầng làm gì thì gần như mọi câu hỏi về hiệu năng của một server Python đều trả lời được bằng cách hỏi: việc này đang chờ hay đang tính, và nó đang chiếm thứ gì trong lúc đó?

Muốn đi sâu hơn nữa, vào bên trong chính trình thông dịch: Bên trong CPython: mọi thứ là object, và import thật ra làm gì.