Bỏ qua để đến nội dung

Threading, Multiprocessing hay asyncio? Cách chọn mô hình đồng thời

Sau ba bài Threading, Multiprocessingasyncio, 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ế

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-bound
measure(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ờ.

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êm
2.000 task asyncio 1.02s +3 MB
2.000 thread 1.64s +66 MB
10.000 task asyncio 1.07s +16 MB
10.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 gian
Tuần tự 2.68s
4 thread (có GIL) 2.51s (gần như không cải thiện)
4 tiến trình 0.78s
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)
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.

Ứ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 asyncio
from 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.

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.

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.

  • Đo trước để biết chương trình CPU-bound hay I/O-bound.
  • I/O-bound, số lượng vừaThreadPoolExecutor: đơn giản, dùng được mọi thư viện.
  • I/O-bound, số lượng rất lớnasyncio: 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.