Bỏ qua đến nội dung

Python Concurrency: Hiểu GIL, Threading và Multiprocessing từ gốc

Hướng dẫn chuyên sâu về Python Concurrency: Hiểu bản chất GIL, phân biệt Threading vs Multiprocessing, tối ưu I/O vs CPU-bound và tư duy kiến trúc backend.

15 phút đọcEnglish
Python Concurrency: Hiểu GIL, Threading và Multiprocessing từ gốc

Nếu bạn mới ra trường và bắt đầu bước chân vào thế giới Python backend, sớm muộn gì bạn cũng sẽ va phải ba từ khóa kinh điển:

Threading, Multiprocessing và GIL.

Và ngay sau đó là một ma trận những câu hỏi gây hoang mang trong các buổi phỏng vấn kỹ thuật:

“Python có GIL thì threading có chạy song song thật sự được không?”
“Nếu threading bị GIL kìm hãm, sao không dùng multiprocessing cho tất cả mọi thứ?”
“Một Process có tương ứng với một CPU Core không?”
“Nếu Python 3.13+ bỏ GIL (Free-threaded), multiprocessing có trở nên vô dụng không?”

Câu trả lời sách giáo khoa thường chỉ quẳng cho bạn định nghĩa: “GIL là Global Interpreter Lock ngăn nhiều thread thực thi bytecode cùng lúc.” Nhưng định nghĩa đó không giúp bạn thiết kế được một hệ thống backend xử lý hàng nghìn request mỗi giây.

Bài viết này sẽ tháo dỡ toàn bộ cơ chế concurrency của Python từ cấp độ phần cứng CPU Core, hệ điều hành (OS), cho đến CPython runtime để xây dựng một mô hình tư duy (mental model) chuẩn xác nhất.


Phần 1: Nền tảng (Mô hình tư duy)

Để không bao giờ bị rối, bạn phải phân biệt rạch ròi ba khái niệm: Process, Thread và CPU Core.

1. Process vs. Thread vs. CPU Core

  • Process (Tiến trình): Một chương trình đang chạy, có không gian bộ nhớ riêng và một CPython interpreter riêng. Process là thực thể cha — nó có thể sinh ra nhiều Thread con bên trong.
  • Thread (Luồng): Luồng thực thi lệnh nằm bên trong một Process cha. Các Thread trong cùng Process dùng chung bộ nhớ heap của Process đó. Một Process có thể sinh ra n Thread con.
  • CPU Core (Lõi vi xử lý): Phần cứng vật lý thực sự chạy lệnh. Hệ điều hành (OS Scheduler) phân phối Thread và Process lên các Core để thực thi.

Quy tắc nền tảng: Process KHÔNG phải là CPU Core.
Process là khái niệm quản lý của hệ điều hành; Core là phần cứng. OS mới là bên quyết định Core nào chạy Thread nào, tại từng thời điểm.

Mô hình Threading — 1 Process cha, n Thread con, n Core:

Khi bạn dùng threading.Thread(...), Python Process hiện tại đóng vai trò cha và sinh ra các Thread con bên trong. OS Scheduler tự do phân phối mỗi Thread lên một CPU Core khác nhau cùng lúc.

Lưu ý (sẽ mổ xẻ ở Phần 2): OS thật sự đặt các Thread lên các Core khác nhau — nhưng trong CPython chuẩn, GIL chỉ cho phép một Thread chạy Python bytecode tại mỗi thời điểm. Vì vậy các Core dư chỉ có lợi cho tác vụ chờ I/O và C-extension, không có lợi cho tính toán thuần Python.

Mô hình Multiprocessing — n Process cha, n Core:

Khi bạn dùng multiprocessing.Process(...), bạn khởi động n Process độc lập — mỗi cái có bộ nhớ riêng và GIL riêng. OS Scheduler phân phối n Process đó lên n CPU Core song song hoàn toàn.

2. GIL là gì? Chiếc Micro duy nhất trong phòng họp

Nếu OS cho phép các thread chạy trên nhiều core, tại sao CPython lại không làm được điều đó cho code tính toán?

Đó là vì GIL (Global Interpreter Lock).

Hãy tưởng tượng một phòng họp có các diễn giả (Threads) ngồi quanh bàn. Tất cả đều muốn phát biểu (thực thi Python bytecode). Nhưng trong phòng chỉ có duy nhất một chiếc Micro (GIL).

Bất kỳ ai muốn nói đều phải cầm được chiếc Micro. Dù bạn có nhiều cái loa (CPU Cores) gắn ở các góc phòng, tại một thời điểm chỉ có tiếng của người cầm Micro được phát ra.

3. Tại sao GIL lại tồn tại?

GIL không phải là một lỗi (bug); nó là một quyết định thiết kế (design decision) từ những năm 1990 của Guido van Rossum:

  1. Quản lý bộ nhớ đơn giản bằng Reference Counting: CPython theo dõi số lượng tham chiếu trỏ tới mỗi object (PyObject->ob_refcnt). Nếu nhiều thread cùng tăng/giảm biến đếm này mà không có khóa, bộ nhớ sẽ bị rò rỉ (memory leak) hoặc bị giải phóng sớm (dangling pointer gây crash).
  2. C-Extension cực nhanh và dễ viết: Các thư viện C/C++ tích hợp vào Python không cần phải lo lắng về việc lock từng struct nhỏ, giúp hệ sinh thái C-extension của Python bùng nổ (NumPy, SciPy).
  3. Tốc độ đơn luồng (Single-thread performance): Không có overhead từ hàng nghìn fine-grained locks li ti, code đơn luồng của Python chạy nhanh hơn rất nhiều.

Phần 2: Điều tra (CPU-bound vs. I/O-bound)

Hiểu được GIL rồi, giờ hãy trả lời câu hỏi thực tế: Vậy Threading trong Python có vô dụng không?

Câu trả lời là: Hoàn toàn không. Threading cực kỳ mạnh mẽ cho I/O-bound.

1. I/O-bound: Khi GIL tự nguyện “nhường bước”

Hãy xem một kịch bản backend điển hình: Gọi 100 API của bên thứ ba hoặc thực hiện 100 câu query vào PostgreSQL.

Trong phần lớn thời gian chờ mạng (200ms) hoặc chờ đĩa, CPU hoàn toàn rảnh rỗi. CPython được lập trình để: Ngay trước khi thực hiện các tác vụ I/O (network read/write, file I/O, time.sleep()), Thread hiện tại sẽ chủ động GIẢI PHÓNG (RELEASE) GIL.

Nhờ cơ chế này, trong lúc Thread 1 đang chờ dữ liệu mạng từ socket, các thread khác tranh thủ lấy GIL và gửi request tiếp theo. Tổng thời gian hoàn thành 100 requests giảm từ 20 giây xuống còn 0.3 giây!

2. Thử nghiệm Benchmark: Số liệu không biết nói dối

Hãy chạy một thực nghiệm so sánh giữa ba tác vụ trên máy 8-Core:

import time
import requests
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# 1. Tác vụ I/O-bound (Gọi HTTP)
def io_task(url):
    resp = requests.get(url)
    return len(resp.content)

# 2. Tác vụ CPU-bound (Tính toán nặng)
def cpu_task(n):
    count = 0
    for i in range(n):
        count += i * i
    return count

Kết quả benchmark thực tế khi xử lý 10 tác vụ:

Chiến lược 10 Tác vụ I/O-bound (Gọi API 500ms) 10 Tác vụ CPU-bound (100M phép tính)
Tuần tự (Single-thread) ~ 5.20 giây ~ 12.40 giây
10 Threads (ThreadPoolExecutor) ~ 0.58 giây (Nhanh gấp 9x) 🚀 ~ 12.80 giây (Chậm hơn do tranh chấp GIL!) 🔴
10 Processes (ProcessPoolExecutor) ~ 0.65 giây (Tốn RAM hơn) ~ 2.10 giây (Nhanh gấp 6x nhờ 8 Cores)

Bài học rút ra:

  • Với I/O-bound: Threading giải quyết bài toán xuất sắc mà không tốn nhiều RAM.
  • Với CPU-bound: Threading vô tác dụng vì GIL. Bạn phải dùng Multiprocessing (hoặc C-extensions/Rust) để phân phối sang các core khác.

Phần 3: Chẩn đoán (Các cạm bẫy & Sai lầm thường gặp)

Cạm bẫy 1: “Tại sao không dùng Multiprocessing cho mọi thứ?”

Nhiều lập trình viên nghĩ: “Nếu Multiprocessing vừa tận dụng được nhiều Core cho CPU-bound lại vừa chạy được song song, sao không dẹp Threading đi cho khỏe?”

Đây là cái giá đắt đỏ bạn phải trả khi dùng multiprocessing:

  1. Overhead cấp phát bộ nhớ khổng lồ: Mỗi process mới là một phiên bản sao chép đầy đủ của CPython runtime, nạp lại toàn bộ thư viện dependencies. 100 threads tốn ~20MB RAM; 100 processes có thể “ngốn” 4GB - 8GB RAM và làm sập server vì Out Of Memory (OOMKilled).
  2. Chi phí IPC & Serialization (Pickling Overhead): Vì các process có vùng nhớ độc lập, dữ liệu truyền giữa các process (multiprocessing.Queue, Pipe) bắt buộc phải được chuyển thành chuỗi byte (Pickle/Serialize) ở Process gửi rồi giải mã (Unpickle/Deserialize) ở Process nhận. Với các object dữ liệu lớn (như DataFrame hoặc mảng ảnh), thời gian serialize có thể còn lâu hơn thời gian tính toán!

Cạm bẫy 2: “Python có GIL rồi nên code mặc định là Thread-safe!”

Đây là sai lầm nguy hiểm nhất của các lập trình viên chuyển sang viết Python đa luồng.

GIL chỉ bảo vệ tính toàn vẹn nội bộ của CPython interpreter (tránh crash C code), nó KHÔNG bảo vệ tính toàn vẹn của logic ứng dụng của bạn.

CPython chuyển đổi luồng (thread switch) sau mỗi 100 bytecode instructions hoặc sau một khoảng thời gian (sys.getswitchinterval(), mặc định 5ms).

Hãy xem đoạn code sau:

import threading

counter = 0

def increase():
    global counter
    for _ in range(100_000):
        counter += 1  # ❌ KHÔNG HỀ THREAD-SAFE!

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

print(f"Final Counter: {counter}") 
# Kết quả: 341209 (Thay vì 500,000!)

Tại sao? Vì dòng counter += 1 được biên dịch thành 4 bytecode instructions:

  1. LOAD_GLOBAL counter
  2. LOAD_CONST 1
  3. BINARY_OP +
  4. STORE_GLOBAL counter

Nếu thread bị ngắt giữa bước 1 và bước 4, một thread khác sẽ ghi đè lên giá trị cũ. Muốn an toàn, bạn vẫn bắt buộc phải tự dùng threading.Lock().


Phần 4: Giải pháp (Quy tắc chọn công cụ & Kỷ nguyên Free-threading)

1. Bảng phân loại lựa chọn kỹ thuật

Đừng đoán mò. Hãy dùng bảng chẩn đoán sau khi thiết kế hệ thống backend:

Bài toán Đặc điểm workload Công cụ tối ưu Thư viện đề xuất
High I/O Concurrency Hàng nghìn kết nối đồng thời, chat realtime, API Gateway, microservices Asyncio (Event Loop đơn luồng) asyncio, FastAPI, httpx, aiohttp
I/O Đơn giản / Legacy Tải file, gọi vài chục third-party API trong codebase đồng bộ cũ Threading (Multi-threading) concurrent.futures.ThreadPoolExecutor
CPU-bound tính toán Resize ảnh, nén dữ liệu, mã hóa, tính toán thống kê thuần Python Multiprocessing (Đa tiến trình) concurrent.futures.ProcessPoolExecutor, celery
Tính toán ma trận / ML Xử lý mảng số học lớn, Deep Learning C/C++ Extensions (Tự nhả GIL) numpy, pandas, polars, torch

2. Tương lai: Free-threaded Python (PEP 703 & Python 3.13+)

Bắt đầu từ Python 3.13, cộng đồng Python đã chính thức giới thiệu bản dựng thử nghiệm Free-threaded CPython (no-GIL) theo đề xuất PEP 703.

Khi chạy ở chế độ no-GIL:

  • CPython thay thế Reference Counting toàn cục bằng Mimalloc / Biased Reference Counting.
  • Các Thread trong cùng 1 Process giờ đây có thể chạy CPU bytecode song song 100% trên nhiều CPU Core.

Vậy nếu bỏ GIL thì Multiprocessing có chết không?

Không. Multiprocessing vẫn sẽ tồn tại và giữ vai trò tối quan trọng vì một thuộc tính duy nhất: Process Isolation (Sự cô lập tiến trình).

  • Chống sập dây chuyền (Crash Containment): Trong mô hình đa luồng, nếu một thread gặp lỗi Segfault (lỗi bộ nhớ C) hoặc deadlock, toàn bộ process sụp đổ. Trong mô hình đa tiến trình, nếu Worker B chết, Worker A và Main Process vẫn sống khỏe re.
  • Giới hạn tài nguyên (Resource Sandboxing): Bạn có thể dùng OS cgroups để giới hạn Process A chỉ được dùng tối đa 2GB RAM và 1 Core CPU. Luồng (thread) không làm được việc này một cách độc lập.
  • Kiến trúc Web Server chuẩn mực (Gunicorn / Uvicorn): Các web server production luôn chạy mô hình Multi-Process Master-Worker để tận dụng tất cả CPU Cores và tự động khởi động lại worker khi có sự cố.

Mô hình tư duy chốt hạ

3 Nguyên lý vàng cần ghi nhớ:

  1. Process ≠ Core: 1 Process (cha) chứa n Thread (con) dùng chung một heap, và OS có thể trải n Thread đó lên n Core — y như cách nó trải n Process độc lập lên n Core. Core là phần cứng; Process và Thread là khái niệm của OS được xếp lên phần cứng đó.
  2. GIL chỉ chặn CPU-bound bytecode: Threading trong Python vẫn tăng tốc vượt trội cho I/O-bound vì CPython tự động nhả GIL khi đợi mạng/đĩa.
  3. Multiprocessing mang lại Isolation: Kể cả khi Python tương lai không còn GIL, đa tiến trình vẫn là vũ khí số một cho sự ổn định và độ tin cậy của hệ thống production.

Bài viết liên quan

Bạn vừa tìm thấy một trứng phục sinh nhỏ. Cứ tiếp tục khám phá!