Skip to content

Concurrency Trade-offs — GIL, Threading, Multiprocessing, Asyncio

Năm 2022, một startup fintech tại TP.HCM xây hệ thống thanh toán real-time bằng Python. Team lead quyết định dùng threading để xử lý song song hàng ngàn giao dịch mỗi giây — kỳ vọng rằng 8 cores sẽ cho throughput gấp 8 lần. Kết quả sau khi deploy: hiệu năng không tăng, thậm chí latency p99 tệ hơn 15% so với chạy sequential. Nguyên nhân? Ba chữ cái mà mọi Python engineer phải hiểu trước khi chạm vào concurrency — GIL.

Bài này không dạy bạn syntax của threading hay asyncio. Bài này dạy bạn khi nào dùng cái gì, vì sao, và trade-off thực sự là gì — kiến thức mà chỉ có sau khi bạn đã debug production incidents lúc 3 giờ sáng.

GIL — Con Voi Trong Phòng

🎯 Mục tiêu

  • Hiểu GIL là gì và vì sao CPython cần nó
  • Phân biệt rõ I/O-bound vs CPU-bound workload
  • Nắm decision matrix để chọn đúng concurrency model (threading / multiprocessing / asyncio)
  • So sánh trung thực Python vs Go cho concurrency workloads
  • Nhận diện anti-patterns phổ biến khi dùng sai concurrency model

Bức tranh tư duy

Hãy tưởng tượng một quán cà phê ở Đà Nẵng có 4 máy pha espresso (4 CPU cores) nhưng chỉ có một chiếc chìa khoá duy nhất để mở tủ nguyên liệu (GIL). Dù có 4 barista (4 threads), mỗi lần chỉ một barista được mở tủ lấy cà phê để pha. Các barista khác phải đứng chờ.

Nhưng — khi một barista đang chờ nước sôi (I/O wait), họ sẽ trả lại chìa khoá cho barista khác. Chỉ khi barista đang xay cà phê bằng tay (CPU computation) thì họ mới giữ chìa khoá liên tục.

Thread A: ──────🔒────────────────🔓──────
Thread B: ──────────🔒────────🔓──────────
Thread C: ────────────────🔒──────🔓──────

              GIL: chỉ 1 thread chạy Python bytecode tại 1 thời điểm

Khi nào analogy không còn chính xác: GIL thực tế phức tạp hơn — nó có cơ chế "check interval" (mỗi 5ms trong Python 3.2+) để force-release, và C extensions có thể tự release GIL. Nhưng mental model trên đủ chính xác cho 95% quyết định kiến trúc.

Cốt lõi kỹ thuật — GIL bảo vệ gì?

CPython quản lý bộ nhớ bằng reference counting — mỗi object có một biến đếm ob_refcnt. Khi hai thread cùng tăng/giảm biến đếm này mà không có lock, bạn sẽ gặp race condition → memory leak hoặc segfault.

GIL là giải pháp đơn giản nhất: lock toàn cục đảm bảo chỉ một thread chạy Python bytecode tại bất kỳ thời điểm nào.

python
import sys

x = []
# ob_refcnt = 1 (biến x)
print(sys.getrefcount(x))  # 2 (biến x + tham số getrefcount)

# Nếu không có GIL, 2 threads cùng append vào x
# có thể corrupt ob_refcnt → crash

Bốn sự thật quan trọng về GIL:

#Sự thậtHệ quả
1GIL bảo vệ reference counting (memory management)Đây là lý do CPython có GIL — không phải vì "Python chậm"
2GIL được release khi I/O (network, disk, database)Threading hoạt động tốt cho I/O-bound workload
3GIL KHÔNG release khi CPU computation (tính toán, parsing)Threading vô dụng (thậm chí chậm hơn) cho CPU-bound
4C extensions có thể tự release GILNumPy, OpenCV chạy song song thật sự dù dùng threading

⚠️ Hiểu lầm phổ biến

"Python không thể chạy song song" là sai. Python threads không chạy song song cho CPU-bound code. Nhưng multiprocessing cho true parallelism, và nhiều C extensions (NumPy, PIL) release GIL khi tính toán nặng.

Threading — Khi I/O Là Bottleneck

Khi nào threading tỏa sáng?

Threading hoạt động xuất sắc khi bottleneck của bạn là chờ đợi — chờ HTTP response, chờ database query, chờ file I/O. Trong lúc thread A chờ response từ API, GIL được release và thread B có thể chạy.

python
import concurrent.futures
import httpx

def fetch_user_data(user_id: int) -> dict:
    """Fetch user từ external API — I/O-bound operation."""
    response = httpx.get(f"https://api.example.com/users/{user_id}")
    return response.json()

# Sequential: 10 users × 200ms = 2 seconds
# Threaded: 10 users × 200ms / 10 threads ≈ 200ms
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
    user_ids = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
    results = list(executor.map(fetch_user_data, user_ids))

Tại sao nhanh hơn? Khi httpx.get() chờ response từ server, GIL được release. Thread pool sẽ schedule thread khác chạy ngay lập tức — overlap thời gian chờ của nhiều requests.

Khi nào KHÔNG dùng threading

🔴 Code Smell: Threading cho CPU-bound

python
# ❌ Tệ: threading cho tính toán nặng
import threading

def compute_heavy(n: int) -> int:
    """CPU-bound: GIL KHÔNG release ở đây."""
    return sum(i * i for i in range(n))

threads = [
    threading.Thread(target=compute_heavy, args=(10_000_000,))
    for _ in range(4)
]
for t in threads:
    t.start()
for t in threads:
    t.join()

# Kết quả: CHẬM HƠN sequential vì GIL + context switching overhead!

✅ ĐÚNG — Dùng ProcessPoolExecutor cho CPU-bound

python
import concurrent.futures

def compute_heavy(n: int) -> int:
    return sum(i * i for i in range(n))

# Mỗi process có GIL riêng → true parallelism
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
    chunks = [10_000_000] * 4
    results = list(executor.map(compute_heavy, chunks))
    total = sum(results)

Thread safety — Đừng quên

Threading có shared memory — nếu nhiều threads cùng ghi vào một data structure mà không có lock, bạn sẽ gặp race condition. GIL bảo vệ CPython internals, không bảo vệ logic của bạn.

python
import threading

counter = 0
lock = threading.Lock()

def increment():
    global counter
    for _ in range(100_000):
        with lock:  # ← Luôn dùng lock khi shared state
            counter += 1

threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()

assert counter == 400_000  # ✅ An toàn với lock

Multiprocessing — Khi CPU Là Bottleneck

Khi nào multiprocessing tỏa sáng?

Khi workload của bạn là CPU-heavy — tính toán nặng, image processing, ML inference, hash computation — bạn cần true parallelism. Mỗi process có Python interpreter riêng, GIL riêng → chạy song song thật sự trên nhiều CPU cores.

python
import concurrent.futures
import hashlib

def hash_password(password: str) -> str:
    """CPU-intensive: 100K iterations of SHA-256."""
    result = password.encode()
    for _ in range(100_000):
        result = hashlib.sha256(result).digest()
    return result.hex()

# Mỗi process có GIL riêng → true parallelism
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
    passwords = ["pass1", "pass2", "pass3", "pass4"]
    hashes = list(executor.map(hash_password, passwords))

Trade-offs thực tế

Multiprocessing không miễn phí. Mỗi lựa chọn kiến trúc đều có cái giá:

Trade-offChi tiếtImpact
Process startup~50-100ms mỗi processKhông phù hợp cho task chạy <100ms
Memory duplicationMỗi process copy data riêng4 workers × 500MB data = 2GB RAM
IPC complexityDữ liệu phải serialize/deserialize (pickle)Không gửi được socket, file handle, lock
Debugging khó hơnMỗi process có PID riêng, log riêngCần centralized logging

Best for: batch processing, ML inference pipeline, image/video processing, password hashing, data transformation lớn.

💡 Mẹo thực chiến

Dùng initializer parameter trong ProcessPoolExecutor để load model/data một lần per worker thay vì mỗi task:

python
import concurrent.futures

_model = None

def init_worker():
    global _model
    _model = load_heavy_ml_model()  # Chỉ load 1 lần per process

def predict(input_data: dict) -> dict:
    return _model.predict(input_data)

with concurrent.futures.ProcessPoolExecutor(
    max_workers=4,
    initializer=init_worker,
) as executor:
    results = list(executor.map(predict, batch_inputs))

Asyncio — Khi Cần Hàng Ngàn Connections

Khi nào asyncio tỏa sáng?

Khi bạn cần xử lý hàng ngàn I/O connections đồng thời trên một thread duy nhất — web servers, websocket connections, API gateways, web crawlers. Asyncio là cooperative multitasking: một thread, nhiều coroutines, không có GIL issue vì chỉ có một thread.

python
import asyncio
import httpx

async def fetch_user(client: httpx.AsyncClient, user_id: int) -> dict:
    """Fetch một user — await để nhường event loop."""
    response = await client.get(f"/users/{user_id}")
    return response.json()

async def fetch_all_users(user_ids: list[int]) -> list[dict]:
    """Fetch tất cả users đồng thời."""
    async with httpx.AsyncClient(base_url="https://api.example.com") as client:
        tasks = [fetch_user(client, uid) for uid in user_ids]
        return await asyncio.gather(*tasks)

# 1000 concurrent requests trên một single thread!
results = asyncio.run(fetch_all_users(range(1, 1001)))

Key insight: Asyncio không tạo thread mới. Nó chạy event loop trên một thread duy nhất, và mỗi await là một "điểm nhường" — khi coroutine A chờ I/O, event loop chuyển sang coroutine B. Không context switching overhead, không GIL contention.

Trade-offs thực tế

Trade-offChi tiếtImpact
Async ecosystem bắt buộcPhải dùng httpx, aiohttp, asyncpg — không dùng requests, psycopg2Không thể mix sync/async tùy tiện
Một blocking call = đóng băngtime.sleep(5) freeze toàn bộ event loopPhải dùng asyncio.sleep(), await mọi I/O
Debug khó hơnStack traces dài hơn, exception propagation phức tạpCần asyncio.create_task() + proper error handling
Learning curveasync/await syntax đơn giản, nhưng mental model phức tạpDễ viết code "trông async" nhưng thực chất blocking

Best for: FastAPI/Starlette endpoints, websocket servers, API gateways, web crawlers, chat applications.

Blocking call — kẻ thù số 1 của asyncio

python
import asyncio
import time

async def bad_handler():
    """❌ KHÔNG BAO GIỜ làm thế này trong async context."""
    time.sleep(5)  # Blocking! Freeze toàn bộ event loop 5 giây
    return {"status": "done"}

async def good_handler():
    """✅ Dùng asyncio.sleep hoặc run_in_executor cho blocking ops."""
    await asyncio.sleep(5)  # Non-blocking — event loop tiếp tục serve
    return {"status": "done"}

async def acceptable_handler():
    """✅ Wrap blocking call trong executor nếu bắt buộc phải dùng sync lib."""
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(
        None,  # default ThreadPoolExecutor
        sync_legacy_function,  # blocking function
    )
    return result

Decision Matrix — Chọn Đúng Mô Hình

Đây là bảng quyết định mà bạn nên bookmark — nó sẽ cứu bạn khỏi hàng giờ refactor sai model:

WorkloadModelVì sao
10 API calls song songthreadingĐơn giản, I/O-bound, concurrency thấp
10K websocket connectionsasyncioConcurrency cao, I/O-bound, memory hiệu quả
Image processing batchmultiprocessingCPU-bound, cần true parallelism
FastAPI endpointasyncio (built-in)Framework đã handle event loop
ML inference pipelinemultiprocessingCPU-bound, isolate workers, tận dụng nhiều cores
Background email sendingthreading hoặc asyncioI/O-bound, concurrency vừa phải
Data pipeline ETLmultiprocessingCPU-bound transform, large data chunks
Real-time chat serverasyncioHàng ngàn connections, low latency

Flowchart quyết định

Workload của bạn là gì?

├─ Chủ yếu chờ I/O (network, disk, DB)?
│  ├─ Cần < 100 concurrent tasks?
│  │  └─ ✅ threading (đơn giản nhất)
│  └─ Cần > 100 concurrent tasks?
│     └─ ✅ asyncio (memory hiệu quả)

├─ Chủ yếu tính toán CPU?
│  └─ ✅ multiprocessing (true parallelism)

└─ Cả hai (I/O + CPU)?
   └─ ✅ multiprocessing + asyncio trong mỗi worker

Khi Nào Go Thắng Python — Honest Comparison

Go goroutines vs Python — so sánh công bằng

Một kỹ sư trưởng thực thụ không chỉ biết Python — họ biết khi nào Python không phải lựa chọn tốt nhất. Đây là so sánh trung thực:

Go:     goroutine = lightweight (2KB stack) + no GIL + preemptive scheduling
Python: thread    = heavyweight (8MB stack) + GIL + OS scheduling
        coroutine = lightweight + single thread + cooperative scheduling
Tiêu chíGo goroutinesPython asyncioPython threading
Memory per unit~2KB (goroutine)~1KB (coroutine)~8MB (OS thread)
SchedulingPreemptive (Go runtime)Cooperative (await)Preemptive (OS)
GILKhông cóKhông ảnh hưởng (1 thread)Bị giới hạn
Max concurrentHàng triệuHàng trăm ngànHàng ngàn
CPU parallelismBuilt-in (GOMAXPROCS)Không (1 thread)Không (GIL)
DeploymentSingle binaryPython runtime + depsPython runtime + deps
LatencyRất thấp (compiled)Thấp (interpreted)Trung bình

Case study: Penrift Tunnel

Hệ thống tunnel networking cần quản lý hàng ngàn TCP connections đồng thời, mỗi connection thực hiện I/O liên tục. Go xử lý bài toán này tự nhiên với goroutines:

  • Memory: 10K goroutines ≈ 20MB. 10K Python threads ≈ 80GB (không khả thi)
  • Python asyncio cũng handle được (10K coroutines ≈ 10MB), nhưng Go có lợi thế: compiled binary → latency thấp hơn, deployment đơn giản hơn, goroutine leak detection built-in
  • Kết luận thực tế: infrastructure networking tools → Go. Business logic APIs → Python

Góc nhìn kỹ sư trưởng

💡 Nhận định từ Giáo sư Tom

Python cho business logic + APIs (FastAPI, data processing, ML serving). Go cho infrastructure + networking tools (proxies, tunnels, load balancers, CLI tools). Hai ngôn ngữ bổ sung cho nhau, không thay thế nhau.

Đừng rewrite FastAPI service sang Go chỉ vì "Go nhanh hơn". Đừng viết networking proxy bằng Python chỉ vì "team quen Python". Chọn đúng tool cho đúng job.

📌 Performance note

Trước khi chọn concurrency model, hãy profile. Nếu 90% thời gian là database query, threading đơn giản có thể đủ. Đừng dùng asyncio chỉ vì nó "cool". Đừng dùng multiprocessing nếu workload chỉ là fetch API. Đo lường trước, quyết định sau.

Bài Tập Nhanh

Bài 1: Match workload → concurrency model

Cho 3 workloads sau, hãy chọn concurrency model phù hợp nhất:

🧠 Quiz

Câu 1: Web scraper cần crawl 500 URLs

  • [x] A) asyncio — I/O-bound, high concurrency, memory hiệu quả
  • [ ] B) multiprocessing — cần true parallelism
  • [ ] C) threading — đơn giản nhất
  • [ ] D) Sequential — không cần concurrency

💡 Giải thích: 500 concurrent connections là high concurrency I/O-bound workload. asyncio với httpx.AsyncClient sẽ handle hiệu quả trên single thread. threading cũng được nhưng 500 OS threads tốn memory.

🧠 Quiz

Câu 2: Resize 1000 ảnh từ 4K xuống thumbnail

  • [ ] A) asyncio — non-blocking I/O
  • [x] B) multiprocessing — CPU-bound, true parallelism
  • [ ] C) threading — song song hóa I/O
  • [ ] D) threading + asyncio kết hợp

💡 Giải thích: Image resizing là CPU-bound computation (pixel manipulation). GIL sẽ ngăn threading chạy song song. multiprocessing cho mỗi worker một GIL riêng → true parallelism trên nhiều cores.

🧠 Quiz

Câu 3: Gửi 50 emails qua SMTP server

  • [ ] A) asyncio — cần non-blocking
  • [ ] B) multiprocessing — cần parallelism
  • [x] C) threading — I/O-bound, low concurrency, đơn giản nhất
  • [ ] D) Sequential — 50 emails không đáng song song hóa

💡 Giải thích: Gửi email là I/O-bound (chờ SMTP server respond). 50 tasks là low concurrency → threading với ThreadPoolExecutor là giải pháp đơn giản, dễ debug nhất. asyncio cũng được nhưng overkill cho 50 tasks.

Bài 2: Tìm blocking call trong asyncio

🧠 Quiz

Câu 4: Đoạn code asyncio nào có vấn đề?

python
import asyncio
import requests  # ← Chú ý library
import json

async def get_weather(city: str) -> dict:
    response = requests.get(f"https://api.weather.com/{city}")  # Line A
    data = response.json()                                       # Line B
    await asyncio.sleep(0)                                       # Line C
    return data

async def main():
    tasks = [get_weather(city) for city in ["hanoi", "hcm", "danang"]]
    results = await asyncio.gather(*tasks)                       # Line D
  • [x] A) Line A — requests.get() là blocking call, freeze event loop
  • [ ] B) Line B — .json() là CPU-bound
  • [ ] C) Line C — asyncio.sleep(0) vô nghĩa
  • [ ] D) Line D — asyncio.gather không hoạt động với sync functions

💡 Giải thích: requests là sync library — requests.get() block toàn bộ event loop cho đến khi nhận response. Dù có async defawait asyncio.gather(), các tasks sẽ chạy sequential vì không có await nào trong get_weather nhường event loop trước khi request hoàn thành. Fix: dùng httpx.AsyncClient với await client.get().

Production Anti-Pattern

🔥 Anti-pattern: "Asyncio All The Things"

Tình huống thực tế: Team backend quyết định rewrite toàn bộ Django monolith sang FastAPI async. Kết quả sau 3 tháng:

Vấn đề 1 — sync_to_async wrapper mọi nơi

python
# ❌ Wrap sync ORM call — chỉ chuyển blocking sang thread pool
from asgiref.sync import sync_to_async

@sync_to_async
def get_user(user_id: int):
    return User.objects.get(id=user_id)  # Django ORM vẫn blocking bên trong

→ Không có performance gain. Chỉ thêm overhead của thread pool.

Vấn đề 2 — Một requests.get() freeze event loop

python
async def enrich_user(user: dict) -> dict:
    # ❌ Quên rằng requests là sync library
    profile = requests.get(f"https://api.linkedin.com/profile/{user['id']}")
    return {**user, "profile": profile.json()}

→ Event loop đóng băng 2-5 giây mỗi request. Toàn bộ server ngừng serve.

Vấn đề 3 — Fire-and-forget tasks không error handling

python
async def handle_order(order: dict):
    # ❌ Task thất bại sẽ bị nuốt im lặng
    asyncio.create_task(send_notification(order))
    asyncio.create_task(update_inventory(order))
    return {"status": "accepted"}

send_notification throw exception → không ai biết → customer không nhận email → mất khách.

✅ ĐÚNG — Nguyên tắc async thực chiến

Fix 1: Chỉ dùng async ở nơi concurrency thực sự cần thiết

python
# Sync cho logic đơn giản
def calculate_total(items: list[dict]) -> float:
    return sum(item["price"] * item["qty"] for item in items)

# Async chỉ cho I/O concurrent
async def fetch_prices(product_ids: list[int]) -> list[float]:
    async with httpx.AsyncClient() as client:
        tasks = [client.get(f"/prices/{pid}") for pid in product_ids]
        responses = await asyncio.gather(*tasks)
        return [r.json()["price"] for r in responses]

Fix 2: Luôn handle errors cho background tasks

python
async def handle_order(order: dict):
    # ✅ Proper error handling cho fire-and-forget tasks
    task = asyncio.create_task(send_notification(order))
    task.add_done_callback(_handle_task_error)
    return {"status": "accepted"}

def _handle_task_error(task: asyncio.Task):
    if task.exception():
        logger.error(f"Background task failed: {task.exception()}")
        # Alert, retry, hoặc fallback

Fix 3: Wrap blocking calls đúng cách khi bắt buộc dùng sync library

python
import asyncio
import functools

async def enrich_user(user: dict) -> dict:
    loop = asyncio.get_event_loop()
    # ✅ Chạy blocking call trong thread pool, không block event loop
    profile = await loop.run_in_executor(
        None,
        functools.partial(requests.get, f"https://api.linkedin.com/profile/{user['id']}")
    )
    return {**user, "profile": profile.json()}

⚠️ Quy tắc vàng

Đừng chuyển sang async vì "trend". Chỉ dùng async khi bạn có bằng chứng rằng I/O concurrency là bottleneck. Giữ synchronous code cho mọi thứ đơn giản — dễ đọc, dễ debug, dễ test hơn gấp 10 lần.

Tổng Kết

Concurrency ModelKhi nào dùngKhi nào tránhMemoryComplexity
threadingI/O-bound, < 100 tasksCPU-boundCao (8MB/thread)Thấp
multiprocessingCPU-bound, true parallelismNhẹ I/O, cần shared stateRất cao (copy data)Trung bình
asyncioI/O-bound, > 100 connectionsCPU-bound, blocking libsThấp (~1KB/coroutine)Cao
Go goroutinesInfrastructure, networkingKhi team chỉ biết PythonRất thấp (2KB)Trung bình

Nguyên tắc cuối cùng: Profile trước, quyết định sau. Concurrency model sai sẽ tốn hàng tuần refactor. Concurrency model đúng sẽ cứu bạn hàng giờ debug lúc 3 giờ sáng.