Threading, Multiprocessing hay asyncio? Cách chọn mô hình đồng thời
Sau ba bài Threading, Multiprocessing và asyncio, câu hỏi tự nhiên là: dự án của tôi nên dùng cái nào? Bài này tổng hợp lại bằng số liệu đo thật và đưa ra một quy trình ra quyết định cụ thể.
Trong bài này, bạn sẽ:
- So sánh chi phí thực tế của thread và task asyncio
- Biết loại tác vụ nào hợp với mô hình nào
- Nắm sơ đồ quyết định nhanh
- Xem cách kết hợp nhiều mô hình trong một ứng dụng
- Phân tích một số tình huống thực tế
Bước 1: xác định nút thắt cổ chai
Phần tiêu đề “Bước 1: xác định nút thắt cổ chai”Trước khi chọn công cụ, hãy đo xem chương trình dành thời gian vào đâu (xem bài Đo và tối ưu hiệu năng). Một cách nhanh: so sánh thời gian thực với thời gian CPU:
import time
def measure(func, *args): wall0, cpu0 = time.perf_counter(), time.process_time() func(*args) wall = time.perf_counter() - wall0 cpu = time.process_time() - cpu0 kind = "CPU-bound" if cpu / wall > 0.8 else "I/O-bound (chủ yếu là chờ)" print(f"{func.__name__}: wall={wall:.2f}s cpu={cpu:.2f}s -> {kind}")
def compute(): sum(i * i for i in range(5_000_000))
def wait(): time.sleep(1)
measure(compute) # wall≈cpu -> CPU-boundmeasure(wait) # cpu≈0 -> I/O-bound- CPU ≈ thời gian thực: CPU đang bận tính → cần nhiều lõi.
- CPU << thời gian thực: chương trình chủ yếu chờ → cần làm nhiều việc trong lúc chờ.
Bước 2: hiểu chi phí của mỗi mô hình
Phần tiêu đề “Bước 2: hiểu chi phí của mỗi mô hình”Mình chạy cùng một tác vụ “chờ 1 giây” (giả lập request mạng) với số lượng lớn:
thời gian RAM tăng thêm2.000 task asyncio 1.02s +3 MB2.000 thread 1.64s +66 MB10.000 task asyncio 1.07s +16 MB10.000 thread RuntimeError: can't start new thread(CPython 3.13, macOS. Giới hạn số thread phụ thuộc hệ điều hành và cấu hình.)
Mỗi thread hệ điều hành cần stack riêng và bị lập lịch bởi kernel; mỗi task asyncio chỉ là một object Python vài KB. Đó là lý do asyncio được chọn cho server cần giữ hàng chục nghìn kết nối (websocket, chat, crawler lớn).
Còn với tác vụ tính toán thuần Python, chỉ có multiprocessing (hoặc Python free-threaded) cho tốc độ tăng theo số lõi:
Tính tổng 4 × 20 triệu số thời gianTuần tự 2.68s4 thread (có GIL) 2.51s (gần như không cải thiện)4 tiến trình 0.78sBảng so sánh tổng hợp
Phần tiêu đề “Bảng so sánh tổng hợp”| Tiêu chí | threading |
multiprocessing |
asyncio |
|---|---|---|---|
| Song song thật trên nhiều lõi | ❌ (trừ code C nhả GIL, hoặc bản free-threaded) | ✅ | ❌ |
| Phù hợp nhất | I/O-bound, số lượng vừa phải | CPU-bound | I/O-bound, số lượng rất lớn |
| Chi phí mỗi đơn vị | ~MB (stack) | ~chục MB (interpreter) | ~KB |
| Số lượng thực tế | hàng chục - vài trăm | bằng số lõi CPU | hàng chục nghìn |
| Chia sẻ dữ liệu | trực tiếp (cần khoá) | pickle / shared memory | trực tiếp (ít cần khoá) |
| Chuyển ngữ cảnh | bất kỳ lúc nào (preemptive) | hệ điều hành | chỉ tại await (cooperative) |
| Dùng thư viện đồng bộ có sẵn | ✅ | ✅ | ❌ cần thư viện async |
| Độ khó debug | cao (race condition) | trung bình | trung bình (quên await, chặn loop) |
Sơ đồ quyết định
Phần tiêu đề “Sơ đồ quyết định”Chương trình của bạn chậm vì...│├─ Tính toán (CPU-bound)│ ├─ Có thể vector hoá bằng NumPy/Pandas/Polars? ──► Làm điều đó trước tiên!│ ├─ Thư viện C đã nhả GIL (hashlib, zlib, NumPy)? ──► ThreadPoolExecutor│ └─ Code Python thuần ──► ProcessPoolExecutor│└─ Chờ đợi (I/O-bound) ├─ Số tác vụ đồng thời nhỏ (< vài trăm), dùng thư viện đồng bộ (requests, boto3, psycopg2) │ ──► ThreadPoolExecutor ├─ Hàng nghìn kết nối, server mạng, websocket │ ──► asyncio (+ httpx/aiohttp, asyncpg...) └─ Ứng dụng đã là async (FastAPI...) nhưng buộc phải gọi thư viện đồng bộ ──► asyncio.to_thread()Một nguyên tắc hay bị bỏ qua: cách nhanh nhất để làm việc là không làm việc đó. Trước khi song song hoá, hãy kiểm tra thuật toán, cache, gom batch truy vấn database, dùng cấu trúc dữ liệu phù hợp.
Kết hợp các mô hình
Phần tiêu đề “Kết hợp các mô hình”Ứng dụng thực tế thường cần nhiều hơn một mô hình. Ví dụ một dịch vụ nhận ảnh qua mạng rồi tạo thumbnail:
import asynciofrom concurrent.futures import ProcessPoolExecutor
def make_thumbnail(data: bytes) -> bytes: # xử lý ảnh nặng CPU (giả lập) return data[: len(data) // 10]
async def download(i) -> bytes: await asyncio.sleep(0.2) # I/O mạng -> asyncio return bytes(1_000_000)
async def handle(i, pool): data = await download(i) loop = asyncio.get_running_loop() thumb = await loop.run_in_executor(pool, make_thumbnail, data) # CPU -> process return len(thumb)
async def main(): with ProcessPoolExecutor() as pool: async with asyncio.TaskGroup() as tg: tasks = [tg.create_task(handle(i, pool)) for i in range(20)] print([t.result() for t in tasks][:3])
if __name__ == "__main__": asyncio.run(main())- asyncio điều phối hàng loạt thao tác mạng trên một thread.
- Process pool gánh phần tính toán, không chặn event loop.
- Trong trường hợp này, dữ liệu gửi sang process là 1 MB mỗi ảnh - chấp nhận được. Nếu ảnh rất lớn, cân nhắc lưu file tạm và chỉ gửi đường dẫn.
Mẫu phổ biến khác: web server (Gunicorn/Uvicorn) chạy nhiều tiến trình worker, mỗi worker chạy một event loop hoặc một thread pool. Nhờ vậy vừa tận dụng mọi lõi vừa xử lý nhiều kết nối trong mỗi lõi.
Tình huống thực tế
Phần tiêu đề “Tình huống thực tế”1. Script tải 500 file từ S3 bằng boto3.
boto3 là thư viện đồng bộ, số lượng vừa phải → ThreadPoolExecutor(max_workers=32). Chuyển sang asyncio chỉ để làm việc này là tốn công vô ích.
2. Crawler 1 triệu trang web.
Hàng nghìn kết nối đồng thời, thời gian chủ yếu là chờ mạng → asyncio + httpx/aiohttp, giới hạn bằng Semaphore theo từng domain. Phần parse HTML nặng có thể đẩy sang process pool.
3. Tính đặc trưng cho 10.000 ảnh bằng Pillow.
Phần lớn công việc của Pillow chạy trong C và nhiều thao tác nhả GIL, nhưng có cả code Python bao quanh → thử ThreadPoolExecutor trước (đơn giản, không tốn pickle), đo, nếu không tăng tốc thì chuyển ProcessPoolExecutor và truyền đường dẫn file.
4. Mô phỏng Monte Carlo bằng vòng lặp Python.
CPU-bound thuần → trước tiên thử viết lại bằng NumPy (thường nhanh hơn 10-100 lần). Nếu không được, ProcessPoolExecutor chia theo lô lớn.
5. API FastAPI cần gọi thư viện đồng bộ (ví dụ SDK cũ).
Không gọi thẳng trong async def (sẽ chặn loop). Dùng await asyncio.to_thread(sdk.call, ...), hoặc khai báo endpoint là def thường - FastAPI tự chạy nó trong thread pool.
Tương lai: Python free-threaded
Phần tiêu đề “Tương lai: Python free-threaded”Với bản build không GIL (3.13t/3.14t), thread có thể chạy song song code Python thuần. Khi hệ sinh thái thư viện hỗ trợ đầy đủ, ranh giới “thread cho I/O, process cho CPU” sẽ mờ đi: thread sẽ trở thành lựa chọn cho cả tác vụ CPU mà không tốn chi phí pickle. Nhưng điều đó cũng có nghĩa mọi dữ liệu dùng chung bắt buộc phải được đồng bộ hoá đúng.
Kết luận
Phần tiêu đề “Kết luận”- Đo trước để biết chương trình CPU-bound hay I/O-bound.
- I/O-bound, số lượng vừa →
ThreadPoolExecutor: đơn giản, dùng được mọi thư viện. - I/O-bound, số lượng rất lớn →
asyncio: nhẹ, mở rộng tới hàng chục nghìn kết nối. - CPU-bound → vector hoá trước, rồi
ProcessPoolExecutor. - Các mô hình kết hợp tốt với nhau qua
run_in_executor/to_thread. - Nhờ
concurrent.futures, đổi giữa thread và process chỉ tốn một dòng - hãy thử và đo.
Bài tiếp theo: Context manager nâng cao.