← Lập trình Python nâng cao

Bài 12 · Nâng cao · 28 phút· Cập nhật 11/06/2026

GIL & vì sao CPython nhanh dần

Biên soạn bởi Nguyễn Anh Tuấn

GIL (Global Interpreter Lock) là gì, đếm tham chiếu, và vì sao CPython nhanh dần: adaptive interpreter (3.11), JIT thử nghiệm (3.13+).

Đây là khái niệm gây hiểu lầm nhất ở Python. GIL (Global Interpreter Lock) khiến CPython chỉ cho MỘT luồng chạy bytecode tại một thời điểm - dù máy có nhiều lõi. Hệ quả khác hẳn nhau giữa CPU-bound và I/O-bound; chỉnh widget để thấy:

luồng:
1 luồng (tuần tự, 2 phần việc)
2 luồng (song song)
200ms
Tăng tốc ≈ 1.0× - CPU-bound + GIL: chỉ 1 luồng chạy bytecode một lúc → các luồng NỐI TIẾP, gần như không nhanh hơn. Muốn nhanh thật: đa tiến trình hoặc bản free-threaded (hai bài sau).
★ Đo thật trên CPython 3.14.5 (2 luồng): CPU-bound ≈ 1.03× (không nhanh hơn), I/O-bound ≈ 1.96× (gần gấp đôi).

Và đây là benchmark THẬT tạo ra những con số đó - hai luồng so với chạy tuần tự:

bench_gil.py - đo trên máy nhiều lõi

import time, threading

def cpu(n):                       # CPU-bound: tinh toan thuan
    s = 0
    for i in range(n): s += i * i
    return s

def io():                         # I/O-bound: cho doi
    time.sleep(0.3)

def do_2_threads(fn, *a):
    ts = [threading.Thread(target=fn, args=a) for _ in range(2)]
    t = time.perf_counter()
    [x.start() for x in ts]; [x.join() for x in ts]
    return time.perf_counter() - t

# (đo trên một máy; con số tuyệt đối tuỳ máy, nhưng TỈ LỆ thì ổn định)
print("CPU 2 luong:", round(do_2_threads(cpu, 8_000_000), 3), "s  -> ~1.0x (KHONG nhanh hon)")
print("I/O 2 luong:", round(do_2_threads(io), 3), "s  -> ~2x nhanh hon tuan tu")

Kết quả khi chạy

CPU 2 luong: 0.64 s  -> ~1.0x (KHONG nhanh hon)
I/O 2 luong: 0.31 s  -> ~2x nhanh hon tuan tu
  • GIL = khoá toàn cục: mỗi lúc chỉ 1 luồng chạy bytecode Python.
  • CPU-bound: 2 luồng ≈ 1 luồng (đo ≈1,03×) - tranh nhau GIL, không có lời.
  • I/O-bound: 2 luồng ≈ nhanh gấp đôi (đo ≈1,96×) - vì GIL được nhả khi chờ.

Nếu một luồng giữ GIL mãi thì luồng khác "chết đói" (starvation). CPython tránh điều đó bằng switch interval: cứ khoảng 5ms, luồng đang giữ GIL phải nhường lượt. Quan trọng hơn: khi chờ I/O thì nhả GIL ngay.

switch.py

import sys
print(sys.getswitchinterval())     # 0.005  (5 mili-giay)
# sys.setswitchinterval(0.001)     # chuyen luong thuong xuyen hon (hiem khi can)

Kết quả khi chạy

0.005
  • GIL nhả ở 3 lúc: hết ~5ms (switch interval), khi CHỜ I/O, và trong nhiều hàm C nặng.
  • Vì vậy I/O-bound (mạng/đĩa) và "việc C" (NumPy, nén, băm) vẫn hưởng lợi từ đa luồng.
  • switch interval chỉ đổi TẦN SUẤT nhường lượt - KHÔNG cho CPU-bound chạy song song.

Vì sao NumPy “lách” được GIL

Các thư viện tính số nặng (NumPy, Pillow, hashlib…) viết bằng C và chủ động nhả GIL trong lúc làm việc nặng. Nên đẩy phần tính toán xuống chúng vừa nhanh (mã máy, vector hoá) vừa cho phép các luồng Python khác chạy song song trong lúc đó.

GIL tồn tại chủ yếu vì cách CPython quản lý bộ nhớ: đếm tham chiếu. Mỗi object giữ một con số "đang có bao nhiêu nơi trỏ tới"; về 0 thì được giải phóng.

refcount.py

import sys
a = []
print(sys.getrefcount(a))   # gom ca tham chieu TAM khi truyen vao getrefcount
b = a                       # them mot tham chieu
print(sys.getrefcount(a))   # tang them 1

Kết quả khi chạy

2
3
  • Mỗi object có bộ đếm tham chiếu: tăng khi có nơi trỏ tới, giảm khi thôi; về 0 → giải phóng.
  • Hai luồng cùng tăng/giảm bộ đếm KHÔNG khoá → sai → giải phóng nhầm (use-after-free, sập) hoặc rò rỉ.
  • GIL khoá toàn bộ → tăng/giảm refcount luôn an toàn, RẺ, và đơn luồng nhanh. Đánh đổi: không song song bytecode.

Đây chính là nút thắt của free-threading

Bỏ GIL nghĩa là phải làm cho đếm tham chiếu AN TOÀN-LUỒNG mà không quá chậm - bài toán khó, là lý do bản free-threaded chậm hơn ~5-10% ở code đơn luồng (bài cuối phần này).

Từ Python 3.11 (PEP 659), máy ảo tự chuyên biệt hoá lệnh "nóng" lúc chạy. Hãy SOI tận mắt: cùng một hàm, sau khi chạy nhiều lần, bytecode đổi:

adaptive.py

import dis

def add(a, b):
    return a + b

for _ in range(200):           # "ham nong" -> may ao chuyen biet hoa
    add(1, 2)

dis.dis(add, adaptive=True)    # xem bytecode da chuyen biet

Kết quả khi chạy

  LOAD_FAST_BORROW_LOAD_FAST_BORROW (a, b)
  BINARY_OP_ADD_INT                  <-- chuyen biet: cong INT, bo kiem tra kieu
  RETURN_VALUE
  • Máy ảo quan sát lệnh chạy nhiều lần rồi thay bằng phiên bản CHUYÊN BIỆT.
  • BINARY_OP (tổng quát) → BINARY_OP_ADD_INT khi thấy luôn cộng int → bỏ kiểm tra kiểu → nhanh hơn.
  • Tự động, không cần sửa code - lý do lớn khiến CPython 3.11+ nhanh hơn hẳn 3.10.

Vì sao nên giữ kiểu ổn định

Adaptive interpreter thưởng cho code mà một biến/đối tượng giữ ổn định một kiểu (luôn int, luôn list…). Đổi kiểu liên tục khiến máy ảo phải "de-specialize" → mất lợi. Một lý do nữa để dùng type hint & thiết kế dữ liệu nhất quán.
  • Từ 3.13 (PEP 744): JIT THỬ NGHIỆM kiểu "copy-and-patch" - ghép sẵn mảnh mã máy cho bytecode nóng.
  • Tới 3.14 VẪN thử nghiệm; bản binary Windows/macOS đã kèm để bật thử (build có hỗ trợ JIT).
  • Khác adaptive interpreter (luôn bật): JIT dịch hẳn ra mã máy - tiềm năng lớn nhưng còn non.

Trung thực

JIT chưa “bật là nhanh gấp đôi”. Đòn bẩy hiệu năng lớn nhất với bạn lúc này vẫn là: thuật toán & cấu trúc dữ liệu đúng, đẩy phần nặng xuống thư viện C/NumPy, và CHỌN ĐÚNG mô hình đồng thời (hai bài tới). Đừng tối ưu mò trước khi đo (profile).
Loại việcHướngVì sao
I/O-bound (mạng, đĩa)đa luồng / asyncGIL nhả khi chờ → chồng lấn.
CPU-bound (tính toán)đa tiến trình / free-threadedMỗi tiến trình GIL riêng (hoặc bỏ GIL) → song song thật.
Tính số nặngNumPy / thư viện CNhả GIL, vector hoá, chạy mã máy.

Tiếp theo

Hiểu GIL rồi, hai bài tới đưa nó vào thực tế: đa luồng & đa tiến trình (chọn cái nào, đo tăng tốc thật, và cạm bẫy race condition), rồi free-threading - bản Python không-GIL cho song song CPU thật.

Câu hỏi thường gặp

GIL (Global Interpreter Lock) là một khoá toàn cục trong CPython: tại MỘT thời điểm, chỉ MỘT luồng được chạy bytecode Python. Nghĩa là dù máy nhiều lõi, các luồng Python KHÔNG chạy bytecode song song thật - chúng thay phiên nhau giữ GIL.

Ba lúc chính: (1) Hết "switch interval" (mặc định 5ms - xem sys.getswitchinterval()) thì luồng đang giữ GIL nhường cho luồng khác. (2) Khi luồng CHỜ I/O (đọc file, gọi mạng, time.sleep) - nó nhả GIL để luồng khác chạy. (3) Trong nhiều hàm C nặng (vd NumPy, nén, băm) chủ động nhả GIL. Vì vậy I/O-bound và "việc C" vẫn hưởng lợi từ đa luồng.

Chủ yếu vì ĐẾM THAM CHIẾU (ref counting) - cách CPython quản lý bộ nhớ. Mỗi object giữ một bộ đếm "đang có bao nhiêu nơi trỏ tới". Nhiều luồng cùng tăng/giảm bộ đếm này mà không bảo vệ → hỏng (rò rỉ, hoặc giải phóng nhầm → use-after-free → sập). GIL bảo vệ điều đó RẺ và làm code đơn luồng nhanh. Cái giá: không song song bytecode.

Không! Với việc I/O-bound, nhiều luồng vẫn tăng tốc rõ (đo thật ≈1,96× với 2 luồng) vì GIL nhả khi chờ. Chỉ việc CPU-bound THUẦN mới không nhanh hơn vì các luồng phải tranh GIL (đo ≈1,03×). Quy tắc: I/O → luồng; CPU → tiến trình (hoặc free-threading).

Từ Python 3.11 (PEP 659): trình thông dịch QUAN SÁT bytecode đang chạy và TỰ CHUYÊN BIỆT hoá lệnh "nóng". Vd thấy một phép + luôn cộng hai int, nó thay lệnh BINARY_OP tổng quát bằng BINARY_OP_ADD_INT chuyên dụng (bỏ kiểm tra kiểu). Bạn xem được bằng dis(..., adaptive=True). Code không đổi mà chạy nhanh hơn - lý do lớn khiến 3.11+ nhanh hơn 3.10.

Có một JIT THỬ NGHIỆM từ 3.13 (PEP 744, kiểu "copy-and-patch"); tới 3.14 VẪN thử nghiệm (bản binary Windows/macOS đã kèm để bật thử). Nó dịch một số đoạn bytecode nóng thành mã máy lúc chạy. Còn sớm để kỳ vọng tăng tốc lớn; đòn bẩy lớn hiện nay vẫn là adaptive interpreter + chọn đúng mô hình đồng thời.

Được: sys.setswitchinterval(giây). Đặt nhỏ → luồng chuyển thường xuyên hơn (phản hồi nhanh hơn nhưng tốn chi phí chuyển); đặt lớn → ít chuyển hơn. Hiếm khi cần chỉnh; mặc định 5ms hợp lý cho phần lớn việc. Nó KHÔNG làm CPU-bound chạy song song - chỉ đổi tần suất nhường lượt.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

GIL (Global Interpreter Lock) gây ra hệ quả nào trong CPython?

  1. 1

    Đoán rồi đo

    Dùng widget Bước 1; rồi tự viết benchmark threading + time.perf_counter cho CPU-bound và I/O-bound (2 luồng) trên máy bạn.

    Hoàn thành khi: CPU-bound ≈ 1× (không nhanh hơn), I/O-bound ≈ 2× - khớp tinh thần số đo trong bài.

  2. 2

    Switch interval

    In sys.getswitchinterval(). Bằng lời: vì sao con số này KHÔNG giúp CPU-bound chạy song song?

    Hoàn thành khi: Mặc định 0.005 (5ms). Nó chỉ đổi tần suất NHƯỜNG GIL giữa các luồng, vẫn chỉ 1 luồng chạy bytecode một lúc.

  3. 3

    Refcount

    Dùng sys.getrefcount trên một object; gán thêm một biến trỏ tới rồi đo lại. Giải thích thay đổi.

    Hoàn thành khi: Refcount tăng khi có thêm tham chiếu; bạn giải thích GIL bảo vệ việc tăng/giảm này khỏi đua.

  4. 4

    Soi bytecode chuyên biệt

    Viết def add(a,b): return a+b; chạy nó ~200 lần; rồi dis.dis(add, adaptive=True). Thấy opcode gì?

    Hoàn thành khi: Sau khi "nóng", thấy BINARY_OP_ADD_INT (chuyên biệt) thay cho BINARY_OP tổng quát.

  5. 5

    Chọn hướng

    Cho: (a) thu thập 100 URL; (b) nén 1000 ảnh; (c) nhân hai ma trận lớn. Mỗi việc nên I/O-threads, multiprocessing, hay thư viện C (NumPy)?

    Hoàn thành khi: (a) threads; (b) multiprocessing/free-threading; (c) NumPy (C, nhả GIL, vector hoá).