Suốt hơn hai thập niên, câu trả lời chuẩn cho câu hỏi "vì sao thêm luồng không làm Python nhanh hơn" là GIL, khoá thông dịch toàn cục chỉ cho một luồng chạy bytecode tại một thời điểm. Từ Python 3.13, CPython có thêm một bản dựng free-threaded (gọi tắt là "3.13t") cho phép tắt GIL. Chúng tôi cài bản dựng đó trên Apple M4, đo một tác vụ CPU thuần Python với 1, 2, 4 và 8 luồng, quan sát điều gì vỡ khi bỏ GIL, rồi rút ra khi nào đáng dùng cho dự án thật.

Toàn bộ số liệu là output thật, mã nguồn nằm ở code/s7-56. Môi trường: Apple M4 (10 nhân), 16 GB RAM, Python 3.13.16 (bản thường và free-threaded), thêm 3.14.8 để so sánh; hai bản vá mới nhất của mỗi nhánh tại thời điểm đăng. Đây là thử nghiệm trên một máy, một loại tác vụ.

Vấn đề: GIL làm luồng không song song được

GIL (Global Interpreter Lock) bảo vệ bộ đếm tham chiếu và cấu trúc nội bộ của CPython khỏi truy cập đồng thời. Cái giá là mọi luồng Python phải thay phiên nhau; máy 10 nhân vẫn chỉ dùng một nhân cho mã Python thuần. Vì vậy các dự án Python chia hai trường phái: luồng cho việc chờ I/O (xem crawl bất đồng bộ với asyncio), còn việc tính toán nặng thì dùng multiprocessing, tức nhiều tiến trình, mỗi tiến trình một bản sao bộ nhớ và một thông dịch viên riêng.

Hạn chế này hiện rõ ở tiền xử lý văn bản, chấm điểm hàng loạt hay chạy mô hình trên CPU, như đã bàn trong phục vụ mô hình bằng FastAPI và tối ưu suy luận bằng ONNX. Nhiều tiến trình tốn RAM theo số tiến trình và phải tuần tự hoá dữ liệu khi truyền qua lại. Bản free-threaded nhắm tới dùng luồng cho cả những việc này, trên một vùng nhớ chung.

Free-threaded Python là gì

PEP 703 đề xuất làm GIL thành tuỳ chọn; tài liệu "What's New in Python 3.13" ghi nhận kết quả: bản free-threaded có mặt từ 3.13 nhưng ở trạng thái thử nghiệm, không bật mặc định, và tài liệu cảnh báo có thể gặp lỗi cùng mức giảm hiệu năng đơn luồng đáng kể. Nó là tệp thực thi riêng, thường tên python3.13t. Cần nhớ:

  • Biên dịch bằng --disable-gil; trình cài đặt chính thức cho macOS và Windows có thể kèm sẵn tệp này như thành phần tuỳ chọn.
  • Trên bản dựng này, GIL vẫn có thể được bật lại lúc chạy bằng biến môi trường PYTHON_GIL=1 hoặc -X gil=1, và ngược lại tắt bằng PYTHON_GIL=0.
  • Module mở rộng C phải được biên dịch riêng cho bản dựng này. Module nào không khai báo hỗ trợ sẽ khiến GIL bật lại ngay khi import.

Cách nhận biết đúng theo tài liệu HOWTO chính thức về free-threading là phân biệt hai điều: bản dựng (sysconfig.get_config_var("Py_GIL_DISABLED")) và trạng thái lúc chạy (sys._is_gil_enabled()). Chuỗi sys.version cũng ghi rõ "free-threading build".

Cài đặt và kiểm tra bản free-threaded

Để thử free-threaded, chúng tôi cài các bản Python bằng uv python install 3.13.16 3.13.16+freethreaded 3.14.8+freethreaded. Các script đọc đường dẫn trình thông dịch từ biến PY313, PY313T, PY314T (mặc định: python3.13, python3.13t, python3.14t). Tập lệnh check_build.py in các thông tin cần thiết:

import sys, sysconfig, platform

print("phiên bản   :", sys.version.split(" (")[0])
print("bản dựng    :", platform.python_build()[0], "|", "free-threading" in sys.version)
print("Py_GIL_DISABLED:", sysconfig.get_config_var("Py_GIL_DISABLED"))
print("GIL đang bật:", sys._is_gil_enabled())
print("sizeof(None):", sys.getsizeof(None), "byte")

Output thật khi chạy lần lượt bằng python3.13 rồi python3.13t (chạy thêm với PYTHON_GIL=0 cho kết quả y hệt dòng thứ hai):

phiên bản   : 3.13.16
bản dựng    : main | False
Py_GIL_DISABLED: 0
GIL đang bật: True
sizeof(None): 16 byte

phiên bản   : 3.13.16 experimental free-threading build
bản dựng    : main | True
Py_GIL_DISABLED: 1
GIL đang bật: False
sizeof(None): 32 byte

Hai chi tiết đáng chú ý. Thứ nhất, bản free-threaded tự tắt GIL ngay cả khi không đặt biến môi trường, vì không có module nào ép bật lại. Thứ hai, sizeof(None) tăng từ 16 lên 32 byte: đối tượng không thuộc bộ gom rác có phần đầu lớn hơn vì bộ đếm tham chiếu được chia theo luồng. Tài liệu HOWTO nêu đúng chi tiết này, kèm lưu ý bộ cấp phát mimalloc thay pymalloc.

Thử nghiệm: tác vụ CPU với 1, 2, 4 và 8 luồng

Bài toán phải là tính toán thuần Python, không gọi thư viện C nào nhả GIL sẵn, nếu không phép đo sẽ không phân biệt được bản dựng. Chúng tôi đếm các số nguyên nhỏ hơn 1,2 triệu có tổng chữ số chia hết cho 7, bằng vòng lặp thuần. Tổng cộng 8 công việc giống nhau, chia đều cho 1, 2, 4 hoặc 8 luồng; mỗi cấu hình đo 3 lần và lấy trung vị thời gian tường (wall time). Kết quả được kiểm tra bằng assert với đáp án tính trước.

def work(n: int) -> int:
    """Đếm số nguyên có tổng chữ số chia hết cho 7 (vòng lặp thuần Python)."""
    c = 0
    for i in range(n):
        s, x = 0, i
        while x:
            s += x % 10
            x //= 10
        if s % 7 == 0:
            c += 1
    return c

# run(threads): chia N_TASKS việc cho các threading.Thread, đo bằng time.perf_counter(),
# rồi assert tổng kết quả == N_TASKS * EXPECTED (xem cpu_bench.py)

Tập lệnh run_bench.sh chạy tệp này trên bốn cấu hình, hai trong số đó là free-threaded. Đây là output thật (số giây, trung vị 3 lần):

3.13 gil           {"python": "3.13.16", "gil": true, "wall_s": {"1": 1.87, "2": 1.85, "4": 1.82, "8": 1.86}}
3.13t PYTHON_GIL=1 {"python": "3.13.16 experimental free-threading build", "gil": true, "wall_s": {"1": 2.41, "2": 2.47, "4": 2.67, "8": 2.43}}
3.13t PYTHON_GIL=0 {"python": "3.13.16 experimental free-threading build", "gil": false, "wall_s": {"1": 2.46, "2": 1.34, "4": 0.62, "8": 0.56}}
3.14t PYTHON_GIL=0 {"python": "3.14.8 free-threading build", "gil": false, "wall_s": {"1": 1.86, "2": 0.89, "4": 0.52, "8": 0.47}}
Cấu hình1 luồng2 luồng4 luồng8 luồngTăng tốc 8 luồng so với 1 luồng
3.13 có GIL1,87 s1,85 s1,82 s1,86 skhoảng 1,0 lần
3.13t, GIL bật lại2,41 s2,47 s2,67 s2,43 skhoảng 1,0 lần
3.13t, GIL tắt2,46 s1,34 s0,62 s0,56 skhoảng 4,4 lần
3.14t, GIL tắt1,86 s0,89 s0,52 s0,47 skhoảng 4,0 lần

Cách đọc kết quả free-threaded, không thổi phồng. Với GIL, thêm luồng không giúp gì, đúng như lý thuyết. Với bản free-threaded tắt GIL, thời gian giảm gần như tuyến tính tới 4 luồng (2,46 s xuống 0,62 s), rồi gần như đứng yên: 4,4 lần ở 8 luồng thay vì 8 lần. Máy M4 có 10 nhân gồm nhân hiệu năng và nhân tiết kiệm điện nên không thể kỳ vọng tuyến tính tới 8; chúng tôi không tách riêng nguyên nhân nên không khẳng định. So với bản thường ở 8 luồng, bản free-threaded 3.13t nhanh hơn khoảng 3,3 lần (1,86 s xuống 0,56 s).

Máy chạy chung với tiến trình khác nên số đo dao động (lúc máy bận, 3.13t ở 4 luồng có lần mất 1,19 s); bảng là lần chạy lúc máy rảnh nhất, chiều hướng không đổi.

Cái giá đơn luồng của free-threaded

Dòng thứ hai và thứ ba của bảng cho thấy cái giá phải trả của free-threaded. Trên tác vụ này, chạy 1 luồng bằng 3.13t mất 2,41 đến 2,46 s so với 1,87 s của bản thường, chậm hơn khoảng 29 đến 32%. Con số đó lớn hơn nhiều so với tài liệu chính thức: theo HOWTO, trên bộ pyperformance chi phí trung bình đơn luồng dao động từ khoảng 1% trên macOS aarch64 tới 8% trên Linux x86-64. Không mâu thuẫn: pyperformance là trung bình nhiều loại tác vụ, còn tác vụ của chúng tôi là vòng lặp số nguyên thuần. Đây là lý do không nên suy ra "free-threaded chậm 30%" cho mọi chương trình.

Có hai điều có thể kiểm chứng từ bảng. Một, bản 3.14t chạy đơn luồng 1,86 s, gần ngang bản 3.13 thường (1,87 s), nên khoảng cách thu hẹp đáng kể ở 3.14. Hai, từ 2 luồng trở lên bản free-threaded mới thắng: 3.13t ở 2 luồng (1,34 s) đã nhanh hơn bản thường (1,85 s).

Lộ trình chính thức của free-threaded: từ thử nghiệm tới được hỗ trợ

PEP 703 chia việc triển khai thành ba giai đoạn. Giai đoạn I (từ 3.13): bản dựng có sẵn nhưng ghi rõ là thử nghiệm. Giai đoạn II: bản dựng được hỗ trợ chính thức nhưng vẫn là tuỳ chọn. Giai đoạn III: trở thành bản dựng mặc định, và việc này được để cho một PEP trong tương lai. PEP 779 đặt điều kiện để chuyển sang giai đoạn II, trong đó có giới hạn cứng về chi phí hiệu năng 15% (ghi nhận lúc viết PEP là khoảng 10% trên đa số nền tảng và khoảng 3% trên macOS) và mục tiêu chi phí bộ nhớ khoảng 20% (khi đó đo được khoảng 15 đến 20%). Hội đồng chỉ đạo đã chấp nhận PEP này ngày 16/06/2025 cho Python 3.14.

Hệ quả thực tế: trong Python 3.13, free-threaded là thử nghiệm và tài liệu khuyên cẩn trọng; trong 3.14 nó đã là bản dựng được hỗ trợ, và dòng chữ "experimental" biến mất khỏi sys.version. Giai đoạn III (làm bản mặc định) chưa được quyết định.

Free-threaded làm lộ mã không có khoá

Với free-threaded, cái giá lớn hơn hiệu năng là tính đúng đắn. GIL từng che giấu rất nhiều lỗi đồng thời. Chúng tôi viết một bộ đếm dùng chung, 8 luồng, mỗi luồng tăng 200.000 lần, một phiên bản không khoá và một phiên bản có threading.Lock:

def worker():                      # chạy trong 8 luồng, N_ITER = 200_000
    for _ in range(N_ITER):
        if use_lock:
            with lock:
                c.value += 1       # có khoá
        else:
            c.value += 1           # không khoá

Output thật khi chạy bằng 3.13 thường rồi 3.13t với PYTHON_GIL=0 (tập lệnh run_race.sh):

# 3.13 có GIL
GIL bật: True | mong đợi 1600000
  không khoá: 1600000
  có khoá  : 1600000
# 3.13t PYTHON_GIL=0
GIL bật: False | mong đợi 1600000
  không khoá: 286900
  có khoá  : 1600000

Với GIL, bộ đếm không khoá đúng trong lần chạy này, nhưng đó không phải bảo đảm: c.value += 1 gồm nhiều bước bytecode (đọc, cộng, ghi) và về nguyên tắc có thể bị chen ngang; GIL chỉ làm xác suất ấy rất thấp. Khi tắt GIL, các luồng thực sự chạy song song và bộ đếm mất hơn 80% số lần tăng (286.900 trên 1.600.000), còn bản có khoá vẫn đúng tuyệt đối.

Số lần tăng bị mất thay đổi theo từng lần chạy, nhưng kết luận không đổi: mọi trạng thái dùng chung được ghi từ nhiều luồng cần khoá hoặc cấu trúc an toàn luồng.

Tài liệu chính thức nói thêm: các kiểu dựng sẵn như dict, list, set dùng khoá nội bộ để không làm hỏng cấu trúc dữ liệu, nhưng tài liệu khuyến nghị dùng threading.Lock hoặc nguyên thuỷ đồng bộ thay vì dựa vào khoá nội bộ đó. Cũng theo tài liệu, một iterator dùng chung giữa nhiều luồng có thể cho phần tử trùng hoặc thiếu, và đọc frame.f_locals của luồng khác có thể làm sập trình thông dịch.

Module mở rộng C và free-threaded: GIL tự bật lại

Đây là vấn đề thực dụng nhất khi áp dụng cho AI và dữ liệu, vì hầu hết thư viện nặng (NumPy, pandas, PyTorch) là module C. Cơ chế: module nào không khai báo slot Py_mod_gil sẽ làm trình thông dịch bật lại GIL khi import, kèm cảnh báo. Chúng tôi thử hai trường hợp bằng run_ext.sh. Trường hợp một là NumPy từ PyPI: wheel free-threaded cp313t chỉ có tới NumPy 2.4.6, từ 2.5 trở đi chỉ còn cp314t (và cp315t), nên chúng tôi cài NumPy 2.4.6 vào venv của 3.13t và NumPy 2.5.3 vào venv của 3.14t. Trường hợp hai là một extension C tối thiểu, viết theo kiểu khởi tạo một pha cũ và không khai báo Py_mod_gil (legacy_ext.c, biên dịch bằng build_legacy.sh). Output thật của ext_output.txt:

# numpy trong venv 3.13t
trước import: False
numpy 2.4.6 | sau import: False
# numpy trong venv 3.14t
trước import: False
numpy 2.5.3 | sau import: False
# extension C cũ, mặc định
RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module 'legacy_ext', which has not declared that it can run safely without the GIL. To override this behavior and keep the GIL disabled (at your own risk), run with PYTHON_GIL=0 or -Xgil=0.
trước import: False
xin chào từ extension C | sau import: True
# extension C cũ, PYTHON_GIL=0
trước import: False
xin chào từ extension C | sau import: False

Hai bài học về free-threaded. Một, hai bản NumPy này không làm GIL bật lại. Hai, chỉ một module chưa khai báo cũng đủ kéo cả tiến trình về chế độ có GIL: dòng "sau import: True" nghĩa là mọi lợi ích song song biến mất mà không có lỗi nào, chỉ có một cảnh báo dễ bị lọc mất trong log. Cờ PYTHON_GIL=0 ép tắt GIL ("at your own risk"), chỉ nên dùng khi bạn đã tự kiểm chứng module đó an toàn. Trong dự án thật, hãy kiểm tra sys._is_gil_enabled() ở cuối bước khởi động và ghi log hoặc thất bại sớm nếu kết quả là True.

Dùng free-threaded thế nào trong dịch vụ thật

Nếu quyết định thử bản free-threaded, hãy tạo venv bằng đúng 3.13t hoặc 3.14t bằng pip bản mới và kiểm tra pip install thật sự chọn wheel có thẻ cp313t hoặc cp314t, đối chiếu từng thư viện C với danh sách theo dõi hỗ trợ của cộng đồng mà HOWTO dẫn tới, thêm kiểm tra sys._is_gil_enabled() ở khởi động, rồi đo bằng đúng tải của bạn thay vì tin con số của bài này.

Với tác vụ chia việc được, mẫu thông dụng là concurrent.futures.ThreadPoolExecutor thay cho ProcessPoolExecutor; lợi nhất khi các luồng chia sẻ dữ liệu lớn chỉ đọc (bảng tra cứu, chỉ mục tìm kiếm) mà không phải sao chép sang từng tiến trình. Tác vụ đã nhanh nhờ thư viện C nhả GIL, như Polars vốn đa luồng ở tầng Rust, không có lý do đổi bản dựng. Việc chờ I/O thì asyncio vẫn nhẹ hơn; so với virtual threads Java 21, mỗi hệ sinh thái giải cùng vấn đề theo một hướng.

Đánh đổi: khi nào không nên dùng free-threaded

Tình huốngNên cân nhắc free-threaded?Lý do
CPU thuần Python, chia việc được, nhiều nhânCó, thử free-threaded và đoBảng trên: giảm 2,46 s xuống 0,56 s ở 8 luồng
Chương trình chủ yếu đơn luồngKhông dùng free-threadedPhải trả chi phí đơn luồng và bộ nhớ, không có lợi ích
Phụ thuộc nhiều module C chưa hỗ trợChưaGIL tự bật lại, chạy không nhanh hơn
Mã cũ chia sẻ trạng thái, không có khoáCần rà soát trướcBộ đếm mất hơn 80% giá trị trong thử nghiệm
Dịch vụ production, cần ổn địnhƯu tiên free-threaded 3.14 trở lên3.13 còn là thử nghiệm theo tài liệu; 3.14 là bản dựng được hỗ trợ
RAM chật (VPS nhỏ)Cẩn trọngĐối tượng lớn hơn, bộ cấp phát khác; PEP 779 nêu mức tăng bộ nhớ khoảng 15 đến 20%

Một giới hạn quan trọng của thử nghiệm free-threaded này: chúng tôi chỉ đo một tác vụ, một máy, và chưa đo bộ nhớ đỉnh. Với dịch vụ AI chạy trên VPS nhỏ như mô tả trong dự án chatbot RAG end-to-end, nên thử free-threaded với một bản sao staging và đo RSS cùng độ trễ p95 trước khi quyết định. Mô hình chạy trên GPU không đổi khi bỏ GIL, vì phép tính nằm ở thư viện C hoặc CUDA.

Lỗi thường gặp

  • Tin vào sys.version mà quên kiểm tra trạng thái lúc chạy: bản free-threaded vẫn có thể đang chạy có GIL.
  • Bỏ qua cảnh báo khi import module chưa hỗ trợ: GIL bật lại, đo không nhanh hơn và kết luận nhầm rằng free-threaded vô dụng.
  • Đo bằng tác vụ gọi thư viện đã nhả GIL: hai bản dựng cho kết quả như nhau, phép đo không phân biệt được gì.
  • Dùng PYTHON_GIL=0 để "ép nhanh": bỏ qua cảnh báo an toàn của module, dễ dẫn tới sai dữ liệu khó tái hiện.

Checklist

  • Xác nhận đúng bản free-threaded: Py_GIL_DISABLED là 1 và sys._is_gil_enabled() là False ở cuối bước khởi động.
  • Đo tác vụ thật với 1, 2, 4, 8 luồng và so với ProcessPoolExecutor trước khi đổi kiến trúc.
  • Rà soát trạng thái dùng chung (khoá hoặc hàng đợi) và kiểm tra từng thư viện có module C; chạy thử với -W error để cảnh báo GIL bật lại thành lỗi.
  • Đo bộ nhớ đỉnh và độ trễ p95 trước và sau khi chuyển.

Cài đặt để chạy lại: đặt PY313, PY313T, PY314T như trên, tạo .venv313t (numpy==2.4.6) và .venv314t (numpy==2.5.3), rồi chạy check_build.py, run_bench.sh, run_race.sh và run_ext.sh trong code/s7-56. Các tệp bench_output.txt, race_output.txt, check_output.txt và ext_output.txt là output đã dán ở trên.

Kết series

Đây là bài cuối của series "Python, AI & Data". Chúng ta đã đi từ môi trường Python cho dự án dữ liệu và crawl dữ liệu, qua machine learning, LLM, RAG và agent, đến MLOps, giám sát sau triển khai và pipeline dữ liệu giá. Free-threaded hứa hẹn thật, nhưng chỉ đáng dùng khi bạn đã đo trên tải thật, kiểm tra từng phụ thuộc và rà lại mọi trạng thái dùng chung. Nếu cần đội ngũ có kinh nghiệm đưa dịch vụ AI vào vận hành ổn định, hãy liên hệ Sudo.