GIL trong Python là gì và ảnh hưởng thế nào tới hiệu năng

1. Tổng quan

Nhiều bạn chuyển từ Java hay Go sang Python gặp cùng một chuyện. Chạy 4 thread trên máy 8 core, nhưng chương trình không nhanh hơn, có khi còn chậm hơn. Thủ phạm thường là GIL (Global Interpreter Lock).

Bài này giải thích GIL là gì và vì sao nó tồn tại. Bạn sẽ đo được ảnh hưởng của GIL lên code CPU-bound và I/O-bound. Cuối bài, bạn biết lúc nào nên dùng threading, lúc nào dùng multiprocessing, và bản build free-threaded của Python 3.13+ thay đổi điều gì.

2. GIL là gì?

GIL là một mutex trong CPython, bản cài đặt Python phổ biến nhất. Tại mỗi thời điểm, trong một process, chỉ một thread được thực thi bytecode Python.

Bạn vẫn tạo được nhiều thread. Các thread vẫn là thread thật của hệ điều hành. Nhưng muốn chạy bytecode, thread phải giữ GIL. Các thread khác phải chờ.

2.1. Vì sao CPython cần GIL?

Lý do chính là cơ chế quản lý bộ nhớ bằng reference counting. Mỗi object có một bộ đếm số tham chiếu tới nó:

import sys

order = {"id": 1, "total": 250_000}
print(sys.getrefcount(order))

alias = order
print(sys.getrefcount(order))
2
3

sys.getrefcount trả về số tham chiếu. Kết quả luôn lớn hơn 1 đơn vị vì chính lời gọi hàm cũng tạo một tham chiếu tạm. Khi gán alias = order, bộ đếm tăng thêm 1.

Nếu hai thread cùng tăng hoặc giảm bộ đếm này mà không có khóa, bộ đếm có thể sai. Hậu quả là memory leak, hoặc tệ hơn, object bị giải phóng khi vẫn còn người dùng. Đặt một khóa toàn cục là cách đơn giản nhất để bảo vệ mọi object. Nó cũng giúp viết C extension dễ hơn và làm code đơn luồng chạy nhanh.

2.2. Thread nhả GIL khi nào?

Thread đang giữ GIL sẽ nhả nó trong hai trường hợp:

  1. Khi chờ I/O: đọc file, gọi mạng, time.sleep… Các hàm này nhả GIL trước khi block.
  2. Khi hết “lượt”: interpreter định kỳ yêu cầu thread đang chạy nhả GIL để thread khác có cơ hội chạy.

Bạn xem được khoảng thời gian của một lượt:

import sys

print(sys.getswitchinterval())
0.005

Mặc định là 5 ms. Bạn có thể đổi bằng sys.setswitchinterval(), nhưng gần như không bao giờ cần.

3. Ảnh hưởng tới code CPU-bound

Hãy đo thử. Hàm sau đếm số nguyên tố, là việc thuần tính toán:

import time
from concurrent.futures import ThreadPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


def run_sequential(jobs: list[int]) -> float:
    start = time.perf_counter()
    for job in jobs:
        count_primes(job)
    return time.perf_counter() - start


def run_threads(jobs: list[int]) -> float:
    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(count_primes, jobs))
    return time.perf_counter() - start


if __name__ == "__main__":
    jobs = [200_000] * 4
    print(f"Tuần tự : {run_sequential(jobs):.2f}s")
    print(f"4 thread: {run_threads(jobs):.2f}s")

run_sequential chạy 4 job lần lượt. run_threads giao 4 job cho 4 thread. Kết quả trên một máy 8 core (chỉ để tham khảo):

Tuần tự : 2.31s
4 thread: 2.40s

Bốn thread không nhanh hơn. Thậm chí còn chậm hơn một chút. Vì GIL, các thread thay nhau chạy trên một core. Phần chậm thêm là chi phí tranh giành GIL và chuyển ngữ cảnh.

Lưu ý: Đây là lỗi phổ biến nhất. Thêm thread vào code tính toán thuần Python trên CPython có GIL không làm nó nhanh hơn.

4. Ảnh hưởng tới code I/O-bound

Với code chủ yếu chờ I/O, tình hình khác hẳn. Ta mô phỏng việc gọi một PaymentService từ xa mất 0,5 giây:

import time
from concurrent.futures import ThreadPoolExecutor


def charge_payment(order_id: int) -> str:
    time.sleep(0.5)  # giả lập gọi HTTP tới cổng thanh toán
    return f"order-{order_id}: OK"


if __name__ == "__main__":
    order_ids = list(range(10))

    start = time.perf_counter()
    for oid in order_ids:
        charge_payment(oid)
    print(f"Tuần tự  : {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=10) as pool:
        list(pool.map(charge_payment, order_ids))
    print(f"10 thread: {time.perf_counter() - start:.2f}s")
Tuần tự  : 5.01s
10 thread: 0.50s

Nhanh gấp 10 lần. time.sleep, cũng như socket hay đọc file, nhả GIL trong lúc chờ. Mười thread cùng chờ song song. Chúng chỉ cần GIL trong khoảnh khắc ngắn để xử lý kết quả.

Kết luận: thread trong Python vẫn rất hữu ích cho I/O-bound, ví dụ gọi API, truy vấn database hay tải file. Với I/O số lượng rất lớn, asyncio cũng là lựa chọn tốt.

5. Vượt qua GIL với multiprocessing

Muốn tận dụng nhiều core cho việc tính toán, cách chuẩn là dùng nhiều process. Mỗi process có interpreter riêng và GIL riêng.

import time
from concurrent.futures import ProcessPoolExecutor


def count_primes(limit: int) -> int:
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


if __name__ == "__main__":
    jobs = [200_000] * 4
    start = time.perf_counter()
    with ProcessPoolExecutor(max_workers=4) as pool:
        results = list(pool.map(count_primes, jobs))
    print(results)
    print(f"4 process: {time.perf_counter() - start:.2f}s")
[17984, 17984, 17984, 17984]
4 process: 0.68s

So với 2,31 giây khi chạy tuần tự, ta nhanh hơn khoảng 3,4 lần. Không đạt đủ 4 lần vì phải tốn chi phí khởi tạo process.

Khối if __name__ == "__main__": là bắt buộc. Trên Windows và macOS, process con được tạo bằng cách import lại module. Thiếu khối này, process con sẽ lại tạo pool mới và chương trình bị lỗi.

Lưu ý: Multiprocessing có cái giá riêng. Tham số và kết quả phải được serialize bằng pickle để truyền giữa các process. Nếu bạn gửi object lớn, như DataFrame hàng trăm MB, cho một tác vụ nhỏ, chi phí copy có thể lớn hơn phần lợi thu được. Lambda và hàm lồng nhau cũng không pickle được.

6. C extension có thể nhả GIL

Nhiều thư viện viết bằng C chủ động nhả GIL khi chạy phần nặng, ví dụ NumPy, hashlib hay zlib. Khi đó thread vẫn chạy song song thật:

import hashlib
import os
import time
from concurrent.futures import ThreadPoolExecutor

payloads = [os.urandom(50_000_000) for _ in range(4)]


def digest(data: bytes) -> str:
    return hashlib.sha256(data).hexdigest()


if __name__ == "__main__":
    start = time.perf_counter()
    for p in payloads:
        digest(p)
    print(f"Tuần tự : {time.perf_counter() - start:.2f}s")

    start = time.perf_counter()
    with ThreadPoolExecutor(max_workers=4) as pool:
        list(pool.map(digest, payloads))
    print(f"4 thread: {time.perf_counter() - start:.2f}s")
Tuần tự : 0.98s
4 thread: 0.27s

hashlib nhả GIL khi băm dữ liệu lớn (từ khoảng 2 KB trở lên). Vì vậy 4 thread chạy trên 4 core. Thực tế, GIL chỉ chặn bytecode Python, không chặn mã máy của thư viện native.

7. GIL không làm code của bạn thread-safe

Nhiều người nghĩ “đã có GIL thì khỏi cần lock”. Điều này sai. GIL bảo vệ trạng thái nội bộ của interpreter, không bảo vệ logic của bạn. Thread có thể bị chuyển ra ngoài giữa hai câu lệnh bất kỳ.

import threading
import time


class BankAccount:
    def __init__(self) -> None:
        self.balance = 0
        self._lock = threading.Lock()

    def deposit_unsafe(self, amount: int) -> None:
        current = self.balance
        time.sleep(0)  # nhả GIL, mô phỏng một thao tác I/O xen giữa
        self.balance = current + amount

    def deposit_safe(self, amount: int) -> None:
        with self._lock:
            current = self.balance
            time.sleep(0)
            self.balance = current + amount


def run(method_name: str, threads: int = 10, times: int = 100) -> int:
    account = BankAccount()
    method = getattr(account, method_name)

    def worker() -> None:
        for _ in range(times):
            method(1)

    pool = [threading.Thread(target=worker) for _ in range(threads)]
    for t in pool:
        t.start()
    for t in pool:
        t.join()
    return account.balance


if __name__ == "__main__":
    print("unsafe:", run("deposit_unsafe"))
    print("safe  :", run("deposit_safe"))
unsafe: 112
safe  : 1000

Ta kỳ vọng 10 × 100 = 1000. Ở deposit_unsafe, nhiều thread cùng đọc một giá trị current cũ rồi ghi đè lên nhau, nên mất phần lớn số tiền nạp. time.sleep(0) giúp lỗi lộ ra chắc chắn. Trong code thật, chỗ nhả GIL có thể là một câu log hay một lời gọi database. deposit_safe dùng threading.Lock để biến thao tác đọc-sửa-ghi thành nguyên tử.

Ta viết test bằng pytest để khóa hành vi này. Cài đặt:

pip install pytest
# test_bank_account.py
from bank_account import run


def test_safe_deposit_keeps_correct_balance():
    assert run("deposit_safe", threads=8, times=200) == 1600


def test_unsafe_deposit_loses_updates():
    assert run("deposit_unsafe", threads=8, times=200) < 1600
pytest -q test_bank_account.py
..                                                       [100%]
2 passed in 0.41s

Test thứ hai chứng minh rằng race condition có thật dù CPython có GIL.

8. Python free-threaded: tương lai không GIL

Từ Python 3.13, CPython có bản build free-threaded (PEP 703) cho phép tắt GIL. Thường bạn chạy bản này qua lệnh python3.13t hoặc python3.14t. Ở Python 3.14, bản này đã được hỗ trợ chính thức (PEP 779), nhưng vẫn chưa phải mặc định.

Kiểm tra trạng thái GIL lúc chạy:

import sys
import sysconfig

print("Build hỗ trợ free-threading:", bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL đang bật:", sys._is_gil_enabled())

Chạy bằng bản free-threaded:

python3.14t check_gil.py
Build hỗ trợ free-threading: True
GIL đang bật: False

Với bản này, ví dụ count_primes dùng ThreadPoolExecutor ở mục 3 sẽ thực sự tận dụng được nhiều core.

Tuy vậy, bạn cần cân nhắc vài điểm trước khi chuyển sang:

  • Code đơn luồng chậm hơn một chút, vì interpreter phải dùng các cơ chế đồng bộ tinh vi hơn.
  • C extension phải tương thích. Nếu bạn import một extension chưa khai báo hỗ trợ free-threading, CPython sẽ tự bật lại GIL và in cảnh báo. Bạn có thể ép bằng PYTHON_GIL=0, nhưng làm vậy là tự chịu rủi ro.
  • Race condition càng dễ xảy ra. Ví dụ ở mục 7 không còn được “che chắn” bởi việc chỉ một thread chạy tại một thời điểm.

9. Chọn công cụ nào?

Loại công việc Nên dùng
I/O-bound (HTTP, DB, file) threading, ThreadPoolExecutor, asyncio
CPU-bound thuần Python ProcessPoolExecutor / multiprocessing
CPU-bound trên NumPy, hashlib… Thread vẫn được, vì thư viện nhả GIL
CPU-bound, cần chia sẻ bộ nhớ, thư viện đã tương thích Cân nhắc Python free-threaded (3.13t/3.14t)

Lưu ý: Đừng tối ưu theo cảm tính. Hãy đo bằng time.perf_counter() hoặc cProfile trước. Rất nhiều chương trình “chậm vì GIL” thực ra chậm vì thuật toán hoặc vì truy vấn database.

10. Kết luận

GIL là khóa toàn cục của CPython. Nó đảm bảo chỉ một thread chạy bytecode Python tại một thời điểm, chủ yếu để bảo vệ reference counting.

Hệ quả thực tế gồm bốn điểm:

  • Thread không tăng tốc code CPU-bound thuần Python.
  • Thread vẫn rất hiệu quả cho I/O-bound và cho các thư viện native có nhả GIL.
  • Muốn tận dụng nhiều core cho tính toán, hãy dùng multiprocessing, hoặc thử bản free-threaded của Python 3.13+.
  • GIL không thay thế lock. Dữ liệu dùng chung giữa các thread vẫn cần đồng bộ đúng cách.
pythongilconcurrencymultithreadingperformance