Một hiểu lầm phổ biến là Python tự tạo ra các "luồng ảo". Thực tế trong CPython:
Khi gọi t = threading.Thread(target=...) rồi t.start(), CPython gọi hàm API tầng thấp của hệ điều hành: pthread_create (trên Linux/macOS) hoặc CreateThread (trên Windows).
Mỗi threading.Thread là một 1-to-1 mapping (ánh xạ $1:1$) với một Kernel Thread (OS Thread) thật sự do hệ điều hành quản lý.
┌────────────────────────────────────────────────────────┐
│ Python Application Level │
│ t1 = threading.Thread() t2 = threading.Thread() │
└───────────────────────────┬────────────────────────────┘
│ (PyThread_start_new_thread)
┌───────────────────────────▼────────────────────────────┐
│ CPython Runtime (C-Level) │
│ PyThreadState 1 PyThreadState 2 │
└───────────────────────────┬────────────────────────────┘
│ (pthread_create / CreateThread)
┌───────────────────────────▼────────────────────────────┐
│ Operating System Kernel Level │
│ OS Thread 0x7F81 OS Thread 0x7F82 │
│ [ OS Scheduler điều phối CPU ] │
└────────────────────────────────────────────────────────┘
Mỗi tiểu trình sở hữu một không gian ngăn xếp (C-Stack) riêng biệt do hệ điều hành cấp phát (thường từ 2MB đến 8MB trên Linux/Windows), nhưng toàn bộ dữ liệu đối tượng Python nằm trên Heap thì dùng chung một không gian địa chỉ ảo của tiến trình cha.
Khi CPU chuyển quyền thực thi từ Thread A sang Thread B, hệ điều hành phải thực hiện chuỗi thao tác:
Lưu toàn bộ thanh ghi phần cứng (Registers, Program Counter, Stack Pointer) của Thread A vào bộ nhớ Kernel (Thread Control Block - TCB).
Nạp tập thanh ghi của Thread B từ TCB vào vi xử lý.
Làm vô hiệu hóa hoặc làm bẩn bộ đệm phần cứng (CPU L1/L2/L3 Cache Miss, Translation Lookaside Buffer - TLB Invalidation). Dữ liệu tính toán trước đó của Thread B phải nạp lại từ RAM vào Cache.
Trong CPython, bài toán này tồi tệ hơn vì ngoài OS Context Switch, còn có thêm GIL Contention Overhead (các thread phải liên tục tranh chấp việc khóa/mở cờ GIL thông qua các tín hiệu Mutex/Condition Variable của hệ thống).
CPython quản lý bộ nhớ thông qua cơ chế đếm tham chiếu (Reference Counting). Mỗi đối tượng PyObject đều có trường ob_refcnt. Khi một biến trỏ tới đối tượng, ob_refcnt tăng; khi biến ra khỏi scope, ob_refcnt giảm. Nếu bộ đếm về 0, bộ nhớ được giải phóng.
Nếu không có cơ chế khóa toàn cục, hai thread chạy song song cùng lúc thao tác trên một đối tượng sẽ gây ra Race Condition trên chính trường ob_refcnt, dẫn tới:
Rò rỉ bộ nhớ (Memory Leak) vì không bao giờ về 0.
Giải phóng sớm vùng nhớ đang dùng (Use-after-free), làm tiến trình crash ngay lập tức (Segmentation Fault).
Để tránh phải đặt hàng triệu khóa nhỏ xung quanh từng biến (gây giảm hiệu năng nghiêm trọng cho code đơn luồng), CPython áp dụng một khóa lớn duy nhất: GIL (Global Interpreter Lock).
Quy tắc cốt lõi của CPython:
Dù CPU của bạn có 16 hay 64 nhân vật lý, tại một thời điểm chỉ duy nhất MỘT OS Thread được phép giữ GIL để thực thi CPython Bytecode.
Chạy kịch bản tính toán thuần CPU (đếm số từ 0 đến $100.000.000$):
import time
import threading
COUNT = 100_000_000
def cpu_heavy_task(n):
while n > 0:
n -= 1
# -------------------------------------------------------------
# KỊCH BẢN 1: ĐƠN LUỒNG (SINGLE-THREAD)
# -------------------------------------------------------------
start = time.time()
cpu_heavy_task(COUNT)
single_time = time.time() - start
print(f"1. Đơn luồng (Single-thread) : {single_time:.4f} giây")
# -------------------------------------------------------------
# KỊCH BẢN 2: ĐA LUỒNG (2 THREADS CHIA ĐÔI CÔNG VIỆC)
# -------------------------------------------------------------
t1 = threading.Thread(target=cpu_heavy_task, args=(COUNT // 2,))
t2 = threading.Thread(target=cpu_heavy_task, args=(COUNT // 2,))
start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
multi_time = time.time() - start
print(f"2. Đa luồng (2 Threads) : {multi_time:.4f} giây")
print(f"-> Chênh lệch hiệu năng : Đa luồng CHẬM HƠN {((multi_time/single_time) - 1)*100:.2f}%")
Kết quả đo đạc thực tế trên CPU đa nhân:
1. Đơn luồng (Single-thread) : 3.8214 giây
2. Đa luồng (2 Threads) : 4.1560 giây
-> Chênh lệch hiệu năng : Đa luồng CHẬM HƠN 8.76%
Khi 2 thread cùng chạy tác vụ tính toán:
Cả 2 thread cùng bị gán lên 2 nhân CPU vật lý riêng biệt bởi OS Scheduler.
Thread 1 giữ GIL và thực thi lệnh. Thread 2 bị chặn lại, chuyển sang trạng thái ngủ chờ GIL.
Hết chu kỳ 5ms (sys.getswitchinterval()), Thread 1 bị ép giải phóng GIL.
Hai thread lao vào tranh chấp GIL thông qua tín hiệu Kernel OS. OS phải tốn tài nguyên chuyển mạch (Context Switch), nạp lại CPU cache.
Tổng lượng công việc tính toán không giảm đi chút nào, nhưng hệ thống phải cõng thêm hàng nghìn lần chi phí tranh chấp khóa và đổi ngữ cảnh giữa 2 nhân CPU.
cv2, numpy) lại giải phóng GIL và chạy song song hiệu quả?Đây là ranh giới kỹ thuật quan trọng nhất của lập trình viên Computer Vision và tính toán dữ liệu lớn.
GIL chỉ tồn tại để bảo vệ cấu trúc nội bộ của máy ảo CPython (thao tác trên các kiểu dữ liệu gốc như dict, list, PyObject*, bộ đếm ob_refcnt). Khi mã nguồn chuyển sang thực thi logic thuần túy bằng ngôn ngữ C/C++ trên các mảng bộ nhớ đệm (Raw Memory Buffer), CPython cho phép lập trình viên C chủ động buông tay khỏi GIL.
numpy và OpenCVTrong mã nguồn C/C++ của OpenCV (modules/imgproc hay modules/videoio) và NumPy, trước khi thực hiện các thuật toán nặng, các kỹ sư sử dụng hai macro của CPython:
// 1. Giải phóng GIL: Báo cho CPython biết luồng này không đụng tới PyObject nữa
Py_BEGIN_ALLOW_THREADS
// ---- VÙNG TÍNH TOÁN C++ KHÔNG CÓ GIL (CHẠY SONG SONG THỰC SỰ TRÊN NHÂN CPU KHÁC) ----
// Ví dụ: Giải mã luồng h264 từ RTSP hoặc tính toán ma trận tích chập Gaussian
cv::resize(srcMat, dstMat, cv::Size(width, height));
// -----------------------------------------------------------------------------------
// 2. Xin lại GIL: Trước khi đóng gói kết quả C++ thành PyObject (numpy.ndarray) trả về Python
Py_END_ALLOW_THREADS
[Thread 1: cv2.resize] ──> Py_BEGIN_ALLOW_THREADS ──> [Tính toán C++ thuần trên Core 1] ──> Py_END_ALLOW_THREADS
(GIẢI PHÓNG GIL)
│
[Thread 2: cv2.read] ─────────────────────────────────────────┴──> [Được cấp GIL & Chạy trên Core 2]
So sánh khi xử lý ảnh song song bằng numpy (thao tác tính toán ma trận lớn giải phóng GIL):
import numpy as np
import threading
import time
# Tạo 2 ma trận lớn kích thước 8000 x 8000
MATRIX_A = np.random.rand(8000, 8000).astype(np.float32)
MATRIX_B = np.random.rand(8000, 8000).astype(np.float32)
def numpy_heavy_task(matrix):
# Các phép toán đại số tuyến tính của NumPy được viết bằng C/Fortran (BLAS/LAPACK)
# Tự động giải phóng GIL trong quá trình tính toán
_ = np.sin(matrix) + np.cos(matrix)
# 1. Chạy tuần tự (Sequential)
start = time.time()
numpy_heavy_task(MATRIX_A)
numpy_heavy_task(MATRIX_B)
seq_time = time.time() - start
print(f"1. Tuần tự (Sequential) : {seq_time:.4f} giây")
# 2. Chạy 2 Threads song song
t1 = threading.Thread(target=numpy_heavy_task, args=(MATRIX_A,))
t2 = threading.Thread(target=numpy_heavy_task, args=(MATRIX_B,))
start = time.time()
t1.start()
t2.start()
t1.join()
t2.join()
thread_time = time.time() - start
print(f"2. Đa luồng (2 Threads) : {thread_time:.4f} giây")
print(f"-> Gia tốc thực tế : Nhanh hơn {(seq_time / thread_time):.2f} lần!")
Kết quả thực tế:
1. Tuần tự (Sequential) : 1.2400 giây
2. Đa luồng (2 Threads) : 0.6800 giây
-> Gia tốc thực tế : Nhanh hơn 1.82 lần! (Tận dụng song song 2 nhân CPU vật lý)
Code thuần Python (Vòng lặp for, tính toán số học, biến int, chuỗi str, dict): Nghẽn bởi GIL $\rightarrow$ Đa luồng không tăng tốc. Phải dùng multiprocessing để tách tiến trình.
Tác vụ I/O (Đọc file, gửi gói mạng, socket, đọc frame camera cv2.VideoCapture): Luồng nhường GIL trong lúc chờ dữ liệu phần cứng $\rightarrow$ Dùng threading hoặc asyncio cực kỳ hiệu quả.
Xử lý số liệu lớn với C-Extensions (cv2.cvtColor, cv2.resize, numpy.dot, torch.inference): Đã được giải phóng GIL ở tầng mã C $\rightarrow$ Dùng threading tận dụng được sức mạnh đa nhân mà không tốn chi phí copy bộ nhớ như multiprocessing.
Yêu cầu: Viết một script kiểm tra thời gian thực thi của hàm cv2.GaussianBlur trên 100 ảnh kích thước $1920 \times 1080$ với 2 phương án:
Chạy vòng lặp xử lý tuần tự trên 1 luồng.
Chia đều 100 ảnh cho 4 threading.Thread chạy đồng thời.
Quan sát chỉ số CPU trong Task Manager/htop và đo lường tỷ lệ tăng tốc (Speedup factor).