Ảnh hưởng của GIL đến hiệu suất Python trong thực tế
Nếu bạn đã từng cố gắng tăng tốc ứng dụng Python của mình bằng cách thêm các thread nhưng không thấy sự thay đổi nào, chào mừng bạn đến với thế giới của Global Interpreter Lock (GIL). Trong bài viết này, chúng ta sẽ phân tích cách GIL ảnh hưởng đến hiệu suất đồng thời, đặc biệt trong những khối công việc backend thực tế.
GIL là gì?
Global Interpreter Lock (GIL) là một mutex ngăn cản nhiều thread native thực thi bytecode Python cùng một lúc trong trình thông dịch CPython. Nó giống như một bảo vệ cửa câu lạc bộ: chỉ một thread có thể vào sân nhảy bytecode Python tại một thời điểm, ngay cả khi có không gian cho nhiều hơn. Điều này có nghĩa là:
- Các thread trong Python không thực sự đồng thời khi thực hiện công việc CPU.
- Bạn không nhận được lợi ích hiệu suất miễn phí chỉ bằng cách tạo ra các thread cho tính toán nặng.
- Mã nặng về I/O vẫn có thể hưởng lợi từ threading, vì GIL được giải phóng trong quá trình I/O.
Công việc CPU-Bound và I/O-Bound: Tại sao lại quan trọng?
Đầu tiên, hãy định nghĩa chúng:
- CPU-bound: Mã chủ yếu sử dụng chu kỳ CPU như xử lý dữ liệu, xử lý hình ảnh.
- I/O-bound: Mã chủ yếu chờ đợi như các yêu cầu mạng, gọi cơ sở dữ liệu, I/O tệp.
Tại sao điều này lại quan trọng? Bởi vì GIL gây bất lợi cho bạn nhiều nhất khi mã của bạn là CPU-bound. Đó là lúc các thread tranh giành GIL. Nhưng đối với mã I/O-bound, các thread lần lượt thực hiện tốt, vì GIL được giải phóng trong quá trình I/O.
Ví dụ #1: Nhiệm vụ CPU-Bound (Sử dụng Threads)
Hãy mô phỏng một khối công việc nặng CPU bằng cách sử dụng các thread:
python
import threading
import time
COUNT = 50_000_000
def cpu_heavy():
x = 0
for _ in range(COUNT):
x += 1
start = time.time()
thread1 = threading.Thread(target=cpu_heavy)
thread2 = threading.Thread(target=cpu_heavy)
thread1.start()
thread2.start()
thread1.join()
thread2.join()
print(f"Threads (CPU-bound): {time.time() - start:.2f}s")
Điều bạn sẽ thấy: Hai thread không làm cho quá trình nhanh hơn. Nó có thể thậm chí còn lâu hơn khi chạy một cái sau cái khác.
Tại sao? Bởi vì chúng đều đang tranh giành GIL. Chỉ một thread có thể thực hiện công việc Python thực tế tại một thời điểm.
Ví dụ #2: Nhiệm vụ CPU-Bound (Sử dụng Multiprocessing)
Bây giờ hãy thử khối công việc tương tự bằng cách sử dụng multiprocessing:
python
from multiprocessing import Process
import time
COUNT = 50_000_000
def cpu_heavy():
x = 0
for _ in range(COUNT):
x += 1
start = time.time()
p1 = Process(target=cpu_heavy)
p2 = Process(target=cpu_heavy)
p1.start()
p2.start()
p1.join()
p2.join()
print(f"Processes (CPU-bound): {time.time() - start:.2f}s")
Bây giờ thì nhanh hơn nhiều! Mỗi process có GIL và không gian bộ nhớ riêng — vì vậy chúng có thể chạy song song trên các lõi CPU.
Ví dụ #3: Nhiệm vụ I/O-Bound (Sử dụng Threads)
Hãy mô phỏng một khối công việc backend thực tế — gọi một API chậm:
python
import threading
import time
import requests
URL = "https://httpbin.org/delay/2" # chờ 2 giây trước khi trả lời
def fetch():
response = requests.get(URL)
print(response.status_code)
start = time.time()
threads = [threading.Thread(target=fetch) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"Threads (I/O-bound): {time.time() - start:.2f}s")
Dù mỗi yêu cầu mất 2 giây, toàn bộ chương trình hoàn thành trong khoảng 2 giây, không phải 10.
Tại sao? Bởi vì requests.get() chặn trên I/O và giải phóng GIL, cho phép các thread khác chạy.
Threads hoạt động tốt với I/O
Trong các API backend, bạn thường:
- Gọi các API khác
- Nói chuyện với các cơ sở dữ liệu
- Đọc các tệp
Tất cả đều là I/O-bound, vì vậy threading thực sự có thể giúp ngay cả với GIL. Ví dụ, một endpoint FastAPI đơn giản như:
python
@app.get("/status")
def get_status():
requests.get("https://a-service.com/ping")
return {"status": "ok"}
Có thể mở rộng tốt hơn dưới người dùng đồng thời nếu được phục vụ bởi một máy chủ ASGI dựa trên thread như Uvicorn với workers.
So sánh: asyncio vs multiprocessing vs threading
| Loại Nhiệm Vụ | Công Cụ Tốt Nhất | Tại Sao |
|---|---|---|
| CPU-bound | multiprocessing | Tránh GIL bằng cách sử dụng processes |
| I/O-bound | threading / asyncio | GIL được giải phóng trong I/O |
| Hỗn Hợp | Chia nhỏ công việc | Sử dụng thread cho I/O, process cho CPU |
Tổng kết
- Các thread Python sẽ không giúp bạn tăng tốc mã nặng CPU vì GIL.
- Đối với công việc CPU-bound, hãy sử dụng
multiprocessing, hoặc gọi đến các mở rộng C giải phóng GIL. - Đối với công việc nặng I/O, các thread Python (hoặc
asyncio) rất tốt. - Hãy benchmark mã của bạn trước khi tối ưu hóa. Bạn có thể đang đổ lỗi cho điều sai lầm.
Bạn có thể tham khảo bài viết gốc tại đây.