Python cung cấp ba cách tiếp cận chính để xử lý nhiều tác vụ cùng lúc: đa luồng (multithreading), đa tiến trình (multiprocessing) và asyncio. Việc chọn đúng mô hình là rất quan trọng để tối đa hóa hiệu năng của chương trình và sử dụng tài nguyên hệ thống một cách hiệu quả. (P/S: Đây cũng là một câu hỏi phỏng vấn phổ biến!)
Nếu không có tính đồng thời, một chương trình mỗi lần chỉ xử lý được một tác vụ. Trong các thao tác như tải tệp tin, yêu cầu mạng hoặc nhập liệu người dùng, chương trình sẽ ở trạng thái nhàn rỗi, lãng phí các chu kỳ CPU quý giá. Concurrency (đồng thời) giải quyết vấn đề này bằng cách cho phép nhiều tác vụ chạy hiệu quả cùng lúc.
Nhưng nên sử dụng mô hình nào? Hãy cùng tìm hiểu sâu hơn!
I. Kiến thức nền tảng về lập trình đồng thời
Trước khi đi vào các mô hình đồng thời trong Python, hãy cùng điểm lại một số khái niệm nền tảng.
1. Đồng thời vs Song song
Đồng thời đề cập đến việc quản lý nhiều tác vụ trong cùng một khoảng thời gian, không nhất thiết các tác vụ đó phải thực thi song song cùng lúc. Các tác vụ có thể luân phiên nhau thực thi, tạo cảm giác như thể chúng đang được chạy đồng thời (đa nhiệm). Song song nghĩa là thực sự chạy nhiều tác vụ đồng thời, thường bằng cách tận dụng nhiều lõi CPU cùng một lúc!
2. Chương trình
Bây giờ hãy chuyển sang một số khái niệm cơ bản của hệ điều hành – gồm chương trình, tiến trình và luồng.
Hình minh họa: nhiều luồng có thể tồn tại đồng thời trong một tiến trình duy nhất – đây chính là mô hình đa luồng (do tác giả tự vẽ). Trong một tiến trình, có thể tạo ra nhiều luồng để thực thi song song các công việc khác nhau, tận dụng khả năng đa nhiệm của hệ điều hành.
Chương trình chỉ đơn giản là một tệp tĩnh, ví dụ như một script Python hoặc một tệp thực thi.
Một chương trình nằm trên ổ đĩa ở trạng thái thụ động, và sẽ không hoạt động cho đến khi hệ điều hành (OS) nạp nó vào bộ nhớ để chạy. Khi điều đó xảy ra, chương trình trở thành một tiến trình.
3. Tiến trình
Tiến trình là một thực thể độc lập của một chương trình đang chạy.
Mỗi tiến trình có không gian bộ nhớ riêng, tài nguyên riêng và trạng thái thực thi riêng. Các tiến trình được cách ly với nhau, có nghĩa là một tiến trình không thể can thiệp vào một tiến trình khác trừ khi thông qua các cơ chế như giao tiếp liên tiến trình (IPC) được thiết kế đặc biệt để cho phép điều đó.
Tiến trình thường được phân thành hai loại chính: 1. Tiến trình I/O-bound: Dành phần lớn thời gian để chờ các thao tác input/output (nhập/xuất) hoàn thành, chẳng hạn như truy cập tệp, giao tiếp mạng hoặc chờ người dùng nhập liệu. Trong khi chờ đợi, CPU hầu như ở trạng thái nhàn rỗi. 2. Tiến trình CPU-bound: Dành phần lớn thời gian để thực hiện tính toán (ví dụ: mã hóa video, phân tích số liệu). Những tác vụ này đòi hỏi rất nhiều thời gian CPU.
Vòng đời của một tiến trình: • Một tiến trình bắt đầu ở trạng thái mới khi được tạo. • Sau đó nó chuyển sang trạng thái sẵn sàng, chờ được cấp thời gian CPU. • Nếu tiến trình phải chờ một sự kiện (ví dụ I/O), nó chuyển sang trạng thái chờ. • Cuối cùng, nó kết thúc sau khi hoàn thành nhiệm vụ.
4. Luồng
Luồng là đơn vị thực thi nhỏ nhất bên trong một tiến trình. Một tiến trình đóng vai trò như “container” chứa các luồng, và trong suốt vòng đời của tiến trình đó, có thể tạo và hủy nhiều luồng.
Mỗi tiến trình có ít nhất một luồng – gọi là luồng chính – nhưng nó cũng có thể tạo thêm các luồng phụ khác.
Các luồng chia sẻ bộ nhớ và tài nguyên chung trong cùng một tiến trình, giúp việc trao đổi dữ liệu giữa chúng rất hiệu quả. Tuy nhiên, sự chia sẻ này có thể dẫn đến các vấn đề đồng bộ như điều kiện tranh chấp (race condition) hoặc deadlock nếu không được quản lý cẩn thận. Không như tiến trình, nhiều luồng trong cùng một tiến trình không tách biệt với nhau – chỉ cần một luồng gặp sự cố cũng có thể làm sập toàn bộ tiến trình.
5. Hệ điều hành quản lý luồng và tiến trình như thế nào?
CPU tại mỗi thời điểm chỉ có thể thực thi một tác vụ trên mỗi lõi. Để xử lý nhiều tác vụ, hệ điều hành sử dụng kỹ thuật chuyển ngữ cảnh cưỡng bức (preemptive context switching).
Trong quá trình chuyển ngữ cảnh, OS tạm dừng tác vụ hiện tại, lưu trạng thái của nó và tải trạng thái của tác vụ tiếp theo để thực thi.
Việc chuyển đổi cực nhanh này tạo ra ảo giác rằng các tác vụ dường như được thực thi đồng thời trên một lõi CPU duy nhất.
Đối với tiến trình, chuyển ngữ cảnh tiêu tốn nhiều tài nguyên hơn vì hệ điều hành phải lưu và tải những không gian bộ nhớ riêng biệt. Đối với luồng, việc chuyển đổi ngữ cảnh nhanh hơn do các luồng dùng chung cùng vùng nhớ trong một tiến trình. Tuy nhiên, việc chuyển đổi quá thường xuyên cũng gây ra chi phí overhead (phụ thêm), có thể làm giảm hiệu năng tổng thể.
Thực sự chỉ khi hệ thống có nhiều lõi CPU thì các tiến trình mới có thể chạy song song đồng thời. Mỗi lõi có thể xử lý đồng thời một tiến trình riêng biệt.
II. Các mô hình đồng thời trong Python
Giờ chúng ta hãy khám phá các mô hình đồng thời cụ thể trong Python.
Tóm tắt các mô hình thực thi đồng thời khác nhau (hình do tác giả tự vẽ). Hình minh họa trên so sánh bốn kịch bản: (1) đơn xử lý, đơn luồng – một tiến trình với một luồng; (2) đơn xử lý, đa luồng – một tiến trình với nhiều luồng; (3) đa xử lý, đơn luồng – nhiều tiến trình, mỗi tiến trình một luồng; (4) đa xử lý, đa luồng – nhiều tiến trình, mỗi tiến trình có nhiều luồng. (Các ô code, data, files biểu thị các vùng bộ nhớ/chứa dữ liệu mà chương trình sử dụng khi thực thi; register là các thanh ghi nhỏ, tốc độ cao trong CPU; stack là vùng nhớ ngăn xếp dùng để quản lý lời gọi hàm, biến cục bộ, v.v.)
1. Đa luồng (Multithreading)
Đa luồng cho phép một tiến trình thực thi đồng thời nhiều luồng, trong đó các luồng dùng chung bộ nhớ và tài nguyên (xem sơ đồ 2 và 4 ở trên).
Tuy nhiên, Khóa thông dịch toàn cục của Python (Global Interpreter Lock - GIL) hạn chế hiệu quả của đa luồng đối với các tác vụ nặng về CPU.
Khóa thông dịch toàn cục của Python (GIL)
GIL là một khóa đảm bảo rằng tại mỗi thời điểm chỉ một luồng được quyền điều khiển trình thông dịch Python, nghĩa là chỉ một luồng có thể thực thi bytecode Python tại một thời điểm.
GIL được giới thiệu nhằm đơn giản hóa quản lý bộ nhớ trong Python, bởi nhiều thao tác nội bộ (chẳng hạn việc tạo đối tượng) mặc định không an toàn khi thực thi đa luồng. Nếu không có GIL, nhiều luồng cùng truy cập vào tài nguyên chung sẽ cần các cơ chế khóa hoặc đồng bộ hóa phức tạp để ngăn chặn tình trạng race condition và lỗi hỏng dữ liệu.
Khi nào GIL trở thành nút thắt cổ chai? • Với chương trình đơn luồng, GIL không gây ảnh hưởng vì luồng đó đã độc chiếm trình thông dịch Python. • Với chương trình đa luồng mà chủ yếu chờ I/O (I/O-bound), GIL ít là vấn đề vì các luồng sẽ nhả GIL khi chờ các thao tác I/O. • Với chương trình đa luồng mà chủ yếu dùng CPU (CPU-bound), GIL trở thành một điểm nghẽn lớn. Nhiều luồng cạnh tranh GIL phải thay phiên nhau thực thi bytecode Python.
Một trường hợp thú vị đáng chú ý là khi sử dụng hàm time.sleep – Python thực chất xử lý nó như một thao tác I/O. Hàm time.sleep không tiêu tốn CPU vì trong khoảng thời gian “ngủ” nó không thực hiện tính toán hay chạy bytecode Python nào. Thay vào đó, việc theo dõi thời gian chờ được giao cho hệ điều hành. Trong thời gian luồng “ngủ”, GIL sẽ được giải phóng, cho phép các luồng khác chạy và sử dụng trình thông dịch Python.
2. Đa tiến trình (Multiprocessing)
Đa tiến trình cho phép hệ thống chạy song song nhiều tiến trình, mỗi tiến trình có bộ nhớ, GIL và tài nguyên độc lập. Bên trong mỗi tiến trình đó, có thể có một hoặc nhiều luồng (xem sơ đồ 3 và 4 ở trên).
Đa tiến trình giúp vượt qua những hạn chế của GIL. Điều này khiến nó đặc biệt phù hợp cho các tác vụ CPU-bound đòi hỏi nhiều tài nguyên tính toán.
Tuy nhiên, đa tiến trình cũng tốn nhiều tài nguyên hơn do mỗi tiến trình có không gian bộ nhớ riêng và phát sinh chi phí quản lý tiến trình.
3. Asyncio
Không giống luồng hay tiến trình, asyncio chỉ sử dụng một luồng đơn để xử lý nhiều tác vụ.
Khi viết mã bất đồng bộ với thư viện asyncio, bạn sẽ sử dụng các từ khóa async/await để quản lý các tác vụ.
Các khái niệm chính 1. Coroutines: Các hàm được định nghĩa với async def. Chúng là cốt lõi của asyncio, đại diện cho các tác vụ có thể tạm dừng và tiếp tục sau này. 2. Event loop (vòng lặp sự kiện): Thành phần quản lý việc thực thi các tác vụ (coroutine). 3. Tasks: Nhiệm vụ – lớp bao bọc (wrapper) quanh coroutines. Khi bạn muốn một coroutine thực sự bắt đầu chạy, bạn biến nó thành một task – ví dụ sử dụng asyncio.create_task(). 4. await: Tạm dừng việc thực thi của một coroutine, trả quyền điều khiển lại cho vòng lặp sự kiện.
Cách hoạt động Asyncio vận hành một vòng lặp sự kiện để điều phối việc thực thi các tác vụ. Các tác vụ sẽ tự nguyện “tạm dừng” khi chúng cần chờ một thứ gì đó, ví dụ đợi phản hồi mạng hoặc đọc/xử lý file. Trong khi một tác vụ đang chờ, vòng lặp sự kiện sẽ chuyển sang chạy tác vụ khác, đảm bảo không có thời gian chết vì chờ đợi.
Điều này khiến asyncio đặc biệt phù hợp cho các kịch bản có nhiều tác vụ nhỏ phải chờ đợi nhiều, chẳng hạn xử lý hàng ngàn request web hoặc quản lý các truy vấn cơ sở dữ liệu. Vì mọi thứ chạy trên một luồng duy nhất, asyncio tránh được overhead và sự phức tạp của việc chuyển đổi luồng liên tục.
Sự khác biệt chính giữa asyncio và đa luồng nằm ở cách chúng xử lý các tác vụ phải chờ đợi.
- Đa luồng dựa vào hệ điều hành để chuyển đổi giữa các luồng khi một luồng bị chờ (chuyển ngữ cảnh cưỡng bức). Khi một luồng đang chờ, HĐH sẽ tự động chuyển sang luồng khác.
- Asyncio chạy trên một luồng duy nhất và dựa vào sự hợp tác của các tác vụ – chúng tự “nhường” (tạm dừng) khi cần chờ (đa nhiệm hợp tác).
2 cách để viết mã bất đồng bộ:
phương pháp 1: await coroutine
Khi bạn trực tiếp await một coroutine, việc thực thi của coroutine hiện tại sẽ tạm dừng tại dòng lệnh await cho đến khi coroutine được chờ kia hoàn thành. Các tác vụ bên trong coroutine hiện tại được thực thi tuần tự lần lượt.
Hãy dùng cách này khi bạn cần kết quả của coroutine đó ngay lập tức để tiếp tục các bước tiếp theo.
Mặc dù cách viết này nghe có vẻ giống mã đồng bộ, thực ra không phải vậy. Trong mã đồng bộ, toàn bộ chương trình sẽ bị chặn lại trong thời gian chờ.
Với asyncio, chỉ coroutine hiện tại bị tạm dừng, trong khi phần còn lại của chương trình vẫn có thể tiếp tục chạy. Điều này giúp asyncio không chặn toàn bộ chương trình (non-blocking).
Ví dụ:
Vòng lặp sự kiện sẽ tạm dừng coroutine hiện tại cho đến khi fetch_data hoàn thành:
async def fetch_data(): print("Fetching data...") await asyncio.sleep(1) # Giả lập một cuộc gọi mạng print("Data fetched") return "data" async def main(): result = await fetch_data() # Coroutine hiện tại tạm dừng tại đây print(f"Result: {result}") asyncio.run(main())
phương pháp 2: asyncio.createtask(coroutine)
Khi gọi asyncio.create_task(), coroutine được lên lịch chạy đồng thời dưới nền. Khác với await, coroutine hiện tại tiếp tục thực thi ngay lập tức mà không chờ task được lên lịch kia hoàn thành.
Coroutine được lên lịch sẽ bắt đầu chạy ngay khi vòng lặp sự kiện có cơ hội, mà không cần đợi một lệnh await nào.
Cách này không tạo ra luồng mới; coroutine vẫn chạy trong cùng luồng với vòng lặp sự kiện, và vòng lặp quyết định mỗi task được chạy khi nào.
Cách tiếp cận này cho phép sự đồng thời trong nội bộ chương trình, cho phép nhiều tác vụ chồng chéo thời gian thực thi một cách hiệu quả. (Sau đó bạn sẽ cần await task đó để lấy kết quả và đảm bảo nó đã hoàn thành.)
Hãy dùng cách này khi bạn muốn chạy nhiều tác vụ đồng thời mà không cần kết quả ngay lập tức.
Ví dụ:
Khi thực thi đến dòng asyncio.create_task(), coroutine fetch_data() sẽ được lên lịch bắt đầu chạy ngay khi vòng lặp sự kiện có thể. Điều này có thể diễn ra trước khi bạn await task đó. Ngược lại, ở ví dụ sử dụng await phía trên, coroutine chỉ bắt đầu chạy khi gặp lệnh await nó.
Tóm lại, phương pháp create_task giúp chương trình hiệu quả hơn bằng cách chồng lấp việc thực thi nhiều tác vụ.
async def fetch_data(): # Giả lập một cuộc gọi mạng await asyncio.sleep(1) return "data" async def main(): # Lên lịch thực thi fetch_data() task = asyncio.create_task(fetch_data()) # Giả lập làm một số việc khác await asyncio.sleep(5) # Giờ mới await task để lấy kết quả result = await task print(result) asyncio.run(main())
Các điểm quan trọng khác
• Bạn có thể kết hợp mã đồng bộ và bất đồng bộ trong cùng một chương trình. Vì mã đồng bộ sẽ chặn (blocking) chương trình, ta có thể chuyển nó sang một luồng riêng bằng asyncio.to_thread(). Cách làm này khiến chương trình của bạn thực sự chạy đa luồng. Trong ví dụ dưới đây, vòng lặp sự kiện asyncio chạy trên luồng chính, còn một luồng nền riêng được sử dụng để thực thi hàm sync_task (một hàm đồng bộ giả định):
python
import asyncio
import time def sync_task(): time.sleep(2) return "Completed" async def main(): result = await asyncio.to_thread(sync_task) print(result) asyncio.run(main())
Bạn cũng nên chuyển các tác vụ CPU-bound (tốn nhiều CPU) sang một tiến trình riêng biệt.
III. Khi nào nên dùng mô hình đồng thời nào?
Lưu đồ dưới đây là một cách tham khảo hữu ích giúp bạn quyết định nên sử dụng phương pháp nào trong từng tình huống.
Nếu một tác vụ không phải I/O-bound (tức không bị giới hạn bởi I/O), hãy sử dụng đa tiến trình. Nếu tác vụ đó I/O-bound, xem xét tốc độ I/O: nếu I/O rất chậm, dùng Asyncio; nếu không quá chậm, dùng đa luồng.
- Đa tiến trình: • Phù hợp nhất cho các tác vụ CPU-bound đòi hỏi nhiều tính toán. • Khi bạn cần vượt qua GIL – mỗi tiến trình có trình thông dịch Python riêng, cho phép tận dụng song song đa lõi thực sự.
- Đa luồng: • Tối ưu cho các tác vụ I/O-bound nhanh do tần suất chuyển ngữ cảnh thấp, trình thông dịch Python có xu hướng giữ trên một luồng lâu hơn. • Không lý tưởng cho các tác vụ CPU-bound do ảnh hưởng của GIL.
- Asyncio: • Lý tưởng cho các tác vụ I/O-bound chậm (ví dụ các request mạng kéo dài hoặc truy vấn CSDL lâu) vì nó quản lý thời gian chờ rất hiệu quả, giúp chương trình có khả năng mở rộng tốt. • Không phù hợp cho các tác vụ CPU-bound nếu không di chuyển công việc sang tiến trình khác.
reference: https://towardsdatascience.com/deep-dive-into-multithreading-multiprocessing-and-asyncio-94fdbe0c91f0/ (Clara Chong)