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:
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
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
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
- ▸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
| Loại việc | Hướng | Vì sao |
|---|---|---|
| I/O-bound (mạng, đĩa) | đa luồng / async | GIL nhả khi chờ → chồng lấn. |
| CPU-bound (tính toán) | đa tiến trình / free-threaded | Mỗi tiến trình GIL riêng (hoặc bỏ GIL) → song song thật. |
| Tính số nặng | NumPy / thư viện C | Nhả GIL, vector hoá, chạy mã máy. |
Tiếp theo
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.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
GIL (Global Interpreter Lock) gây ra hệ quả nào trong CPython?
- 1
Đoán rồi đo
Dùng widget Bước 1; rồi tự viết benchmark
threading+time.perf_countercho 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
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
Refcount
Dùng
sys.getrefcounttrê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
Soi bytecode chuyên biệt
Viết
def add(a,b): return a+b; chạy nó ~200 lần; rồidis.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 choBINARY_OPtổng quát. - 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á).