Nút thắt tốc độ của Python: Hiếm khi do bản thân ngôn ngữ
Câu nói “Python chậm lắm” là một chủ đề yêu thích của những người chỉ trích. Mặc dù là một ngôn ngữ thông dịch, nhưng sự chậm trễ cảm nhận được thường không nằm ở cú pháp. Nó nằm ở cách chúng ta xử lý các tác vụ làm đình trệ quá trình thực thi. Nếu bạn đang cào (scrape) 5.000 URL, xử lý hình ảnh 4K hoặc xây dựng một API lưu lượng cao, chiến lược concurrency sẽ quyết định ứng dụng của bạn chạy mượt mà hay ì ách.
Hồi mới vào nghề, tôi đã cố gắng tăng tốc một script xử lý dữ liệu nặng bằng cách dùng “thread”. Thật kinh khủng, thời gian thực thi thực tế lại tăng thêm 15%. Đó là lần đầu tiên tôi chạm trán với Global Interpreter Lock (GIL). Làm chủ hiệu năng Python không chỉ là chạy code song song. Đó là việc xác định nút thắt cổ chai kiến trúc cụ thể nào—CPU hay I/O—đang kìm hãm bạn.
Các khái niệm cốt lõi: Parallelism và Concurrency
Trước khi viết code, chúng ta phải phân biệt giữa việc làm mọi thứ cùng một lúc (Parallelism) và xử lý nhiều việc trong cùng một khoảng thời gian (Concurrency). Hãy tưởng tượng một nhà bếp bận rộn. Parallelism giống như việc thuê bốn đầu bếp chuyên nghiệp để nấu ăn đồng thời. Concurrency giống như một người phục vụ khéo léo đang xoay xở với mười bàn ăn. Người phục vụ không có mặt ở mọi bàn cùng lúc, nhưng họ chuyển đổi nhanh đến mức mọi thực khách đều cảm thấy mình đang được phục vụ.
1. Multiprocessing: “Lực sĩ” gánh vác việc nặng
GIL ngăn cản nhiều thread thực thi bytecode Python cùng một lúc. Ngay cả trên một máy có 16 nhân, một script đa luồng (multithreaded) tiêu chuẩn thường vẫn bị kẹt trên một nhân duy nhất. Multiprocessing lách qua điều này bằng cách tạo ra các instance hoàn toàn mới của trình thông dịch Python. Mỗi process có không gian bộ nhớ và GIL riêng.
Tốt nhất cho: Các tác vụ nặng về CPU (CPU-bound). Nếu bạn đang xử lý các file CSV 100MB, nén ảnh hoặc chạy suy luận máy học (machine learning inference), multiprocessing là cách duy nhất để đạt được 100% hiệu suất trên tất cả các nhân CPU.
2. Multithreading: “Người phục vụ” đợi I/O
Các thread chia sẻ cùng một không gian bộ nhớ, điều này giúp chúng nhẹ hơn nhưng phải chịu sự kiểm soát của GIL. Tuy nhiên, Python rất thông minh: nó giải phóng GIL trong các hoạt động I/O bị chặn (blocking I/O). Trong khi một thread đợi 200ms để database phản hồi, một thread khác có thể nhảy vào và bắt đầu tải xuống một tệp.
Tốt nhất cho: Các tác vụ I/O ở mức vừa phải. Nó hoàn hảo cho một script chạy từ 20 đến 50 yêu cầu API đồng thời, nơi mà chi phí vận hành các process sẽ là quá mức cần thiết (overkill).
3. Asyncio: “Nghệ sĩ tung hứng” hiện đại
Asyncio là đơn luồng (single-threaded) và đơn tiến trình (single-process). Nó sử dụng một “vòng lặp sự kiện” (event loop) để lập lịch cho các tác vụ. Khi một tác vụ chạm tới điểm await—như một yêu cầu mạng—vòng lặp sẽ tạm dừng tác vụ đó và chuyển ngay sang tác vụ tiếp theo. Nó cực kỳ hiệu quả. Trong khi một thread có thể tiêu tốn 8MB RAM, một task async chỉ tốn vài kilobyte.
Tốt nhất cho: I/O có độ đồng thời cao. Nếu bạn cần xử lý 5.000 kết nối WebSocket đồng thời hoặc xây dựng một trình thu thập dữ liệu (scraper) có khả năng mở rộng, asyncio là tiêu chuẩn vàng.
Thực hành: Các tình huống thực tế
Hiệu năng không phải là lý thuyết suông. Hãy xem các mô hình này hoạt động như thế nào dưới áp lực.
Tình huống A: CPU-Bound (Tính toán các số nguyên tố lớn)
Sử dụng thread cho các phép toán là một cái bẫy. GIL giữ chúng chạy tuần tự, và việc chuyển đổi ngữ cảnh (context switching) thực tế còn gây thêm độ trễ. Đây là cách tôi sử dụng ProcessPoolExecutor để phân bổ tải trên bốn nhân vật lý:
import time
from concurrent.futures import ProcessPoolExecutor
def heavy_computation(n):
# Mô phỏng một tác vụ nặng về CPU
return sum(i * i for i in range(n))
def run_parallel():
numbers = [10**7, 10**7, 10**7, 10**7]
start = time.perf_counter()
with ProcessPoolExecutor() as executor:
results = list(executor.map(heavy_computation, numbers))
end = time.perf_counter()
print(f"Multiprocessing tốn: {end - start:.2f} giây")
if __name__ == "__main__":
run_parallel()
Tình huống B: I/O-Bound (Lấy dữ liệu web)
Khi lấy dữ liệu từ hơn 30 API, asyncio tỏa sáng vì nó tránh được chi phí bộ nhớ khổng lồ của các thread cấp hệ điều hành. Nó coi độ trễ mạng là cơ hội để thực hiện các công việc khác.
import asyncio
import aiohttp
import time
async def fetch_url(session, url):
async with session.get(url) as response:
return await response.text()
async def main():
# Mô phỏng 30 yêu cầu
urls = ["https://google.com", "https://python.org", "https://github.com"] * 10
start = time.perf_counter()
async with aiohttp.ClientSession() as session:
tasks = [fetch_url(session, url) for url in urls]
results = await asyncio.gather(*tasks)
end = time.perf_counter()
print(f"Asyncio đã lấy {len(results)} trang trong {end - start:.2f} giây")
if __name__ == "__main__":
asyncio.run(main())
Những bài học xương máu: Mẹo thực tế
Chọn sai mô hình sẽ tạo ra những lỗi cực kỳ khó truy vết. Đây là những gì tôi học được từ việc quản lý các hệ thống thực tế (production):
- Theo dõi RAM của bạn: Multiprocessing rất tốn tài nguyên. Nếu script cơ bản của bạn dùng 100MB, việc khởi chạy 16 process sẽ ngốn ngay lập tức 1.6GB RAM. Luôn tính toán giới hạn bộ nhớ trước khi mở rộng (scale) process.
- Chia sẻ dữ liệu rất tốn kém: Việc di chuyển dữ liệu giữa các process yêu cầu tuần tự hóa (IPC), vốn rất chậm. Nếu bạn cần liên tục thay đổi một dictionary dùng chung lớn, thread sẽ nhanh hơn—nhưng bạn sẽ cần
Lock()để ngăn chặn tình trạng race condition. - Tách biệt công việc CPU: Đừng bao giờ chạy các phép toán nặng trực tiếp bên trong một hàm
async def. Nó sẽ chặn toàn bộ vòng lặp sự kiện, làm đóng băng mọi kết nối khác. Hãy sử dụngloop.run_in_executorđể đẩy nó sang một process riêng biệt. - Tránh thiết kế quá mức (Over-Engineering): Đối với các tác vụ nhỏ, thời gian thiết lập một process pool có thể vượt quá thời gian thực thi tác vụ đó. Nếu một tác vụ mất chưa đầy 50ms, một vòng lặp
forđơn giản thường sẽ nhanh hơn.
Ma trận quyết định
Tôi tuân theo logic đơn giản này khi bắt đầu một dự án mới:
- Nó đang chờ mạng hoặc ổ đĩa?
- Ít hơn 50 kết nối? Sử dụng
threadingcho đơn giản. - Hàng trăm hoặc hàng nghìn? Sử dụng
asynciođể có khả năng mở rộng.
- Ít hơn 50 kết nối? Sử dụng
- Nó đang thực hiện tính toán nặng?
- Luôn luôn sử dụng
multiprocessing.
- Luôn luôn sử dụng
- Tác vụ bao gồm cả hai?
- Xây dựng một lõi
asynciođể xử lý mạng và đẩy phần tính toán sangProcessPoolExecutor.
- Xây dựng một lõi
Lời kết
Phân biệt được các mô hình này là dấu ấn của một kỹ sư cấp cao (senior engineer). Không có công cụ “tốt nhất”, chỉ có công cụ phù hợp cho nút thắt cổ chai cụ thể. Hãy bắt đầu bằng cách profile code của bạn để tìm xem thời gian thực tế đang bị tiêu tốn ở đâu. Một khi bạn biết mình đang đợi CPU hay đợi đường truyền, lựa chọn sẽ trở nên hiển nhiên. Python không chậm—nó chỉ cần một người chỉ huy dàn nhạc đúng đắn.

