Khai thác sức mạnh đa lõi với Asyncio trong Python
Đây là một trong những bài viết của tôi trong cột Đồng thời Python và nếu bạn thấy nó hữu ích, bạn có thể đọc phần còn lại từ đây .
Giới thiệu
Trong bài viết này, tôi sẽ chỉ cho bạn cách thực thi mã Python asyncio trên CPU đa lõi để mở khóa toàn bộ hiệu suất của các tác vụ đồng thời.
Vấn đề của chúng ta là gì?
asyncio chỉ sử dụng một lõi.
Trong các bài viết trước, tôi đã trình bày chi tiết về cơ chế sử dụng Python asyncio. Với kiến thức này, bạn có thể biết rằng asyncio cho phép các tác vụ liên kết với IO thực thi ở tốc độ cao bằng cách chuyển đổi thực thi tác vụ theo cách thủ công để bỏ qua quy trình tranh chấp GIL trong quá trình chuyển đổi tác vụ đa luồng.
Về mặt lý thuyết, thời gian thực thi của các tác vụ liên kết với IO phụ thuộc vào thời gian từ khi bắt đầu cho đến khi phản hồi của thao tác IO và không phụ thuộc vào hiệu suất CPU của bạn. Do đó, chúng tôi có thể khởi tạo đồng thời hàng chục nghìn tác vụ IO và hoàn thành chúng một cách nhanh chóng.
Nhưng gần đây, tôi đang viết một chương trình cần thu thập dữ liệu đồng thời hàng chục nghìn trang web và nhận thấy rằng mặc dù chương trình asyncio của tôi hiệu quả hơn nhiều so với các chương trình sử dụng tính năng thu thập dữ liệu trang web lặp đi lặp lại, nhưng nó vẫn khiến tôi phải chờ đợi lâu. Tôi có nên sử dụng toàn bộ hiệu suất của máy tính không? Vì vậy, tôi đã mở Trình quản lý tác vụ và kiểm tra:
Tôi nhận thấy rằng ngay từ đầu, mã của tôi chỉ chạy trên một lõi CPU và một số lõi khác không hoạt động. Ngoài việc khởi chạy các hoạt động IO để lấy dữ liệu mạng, một tác vụ phải giải nén và định dạng dữ liệu sau khi dữ liệu đó quay trở lại. Mặc dù phần hoạt động này không tiêu tốn nhiều hiệu suất của CPU, nhưng sau nhiều tác vụ hơn, các hoạt động liên quan đến CPU này sẽ ảnh hưởng nghiêm trọng đến hiệu suất tổng thể.
Tôi muốn làm cho các tác vụ đồng thời asyncio của mình thực thi song song trên nhiều lõi. Điều đó có làm giảm hiệu suất của máy tính của tôi không?
Các nguyên tắc cơ bản của asyncio
Để giải câu đố này, chúng ta phải bắt đầu với việc triển khai asyncio cơ bản, vòng lặp sự kiện.
Như thể hiện trong hình, cải thiện hiệu suất của asyncio cho các chương trình bắt đầu với các tác vụ chuyên sâu về IO. Các tác vụ chuyên sâu IO bao gồm các yêu cầu HTTP, đọc và ghi tệp, truy cập cơ sở dữ liệu, v.v. Tính năng quan trọng nhất của các tác vụ này là CPU không chặn và dành nhiều thời gian tính toán trong khi chờ dữ liệu ngoài được trả về, đó là rất khác với một lớp nhiệm vụ đồng bộ khác đòi hỏi CPU phải luôn hoạt động để tính toán một kết quả cụ thể.
Khi chúng tôi tạo một loạt tác vụ asyncio, trước tiên, mã sẽ đặt các tác vụ này vào hàng đợi. Tại thời điểm này, có một luồng được gọi là vòng lặp sự kiện lấy một tác vụ tại một thời điểm từ hàng đợi và thực thi nó. Khi tác vụ đạt đến câu lệnh chờ đợi và chờ đợi (thường là để trả về một yêu cầu), vòng lặp sự kiện sẽ lấy một tác vụ khác từ hàng đợi và thực thi nó. Cho đến khi tác vụ đang đợi trước đó nhận được dữ liệu thông qua một cuộc gọi lại, vòng lặp sự kiện sẽ quay lại tác vụ đang chờ trước đó và hoàn tất việc thực thi phần mã còn lại.
Vì luồng vòng lặp sự kiện chỉ thực thi trên một lõi, nên vòng lặp sự kiện sẽ chặn khi “phần còn lại của mã” chiếm thời gian của CPU. Khi số lượng tác vụ trong danh mục này lớn, mỗi phân đoạn chặn nhỏ sẽ cộng lại và làm chậm toàn bộ chương trình.
Giải pháp của tôi là gì?
Từ đó, chúng ta biết rằng các chương trình asyncio chạy chậm lại vì mã Python của chúng ta chỉ thực hiện vòng lặp sự kiện trên một lõi và quá trình xử lý dữ liệu IO khiến chương trình chạy chậm lại. Có cách nào để bắt đầu một vòng lặp sự kiện trên mỗi lõi CPU để thực thi nó không?
Như chúng ta đã biết, bắt đầu với Python 3.7, tất cả mã asyncio được khuyến nghị thực thi bằng phương thức asyncio.run
, đây là cách trừu tượng hóa cấp cao gọi vòng lặp sự kiện để thực thi mã thay thế cho mã sau:
try:
loop = asyncio.get_event_loop()
loop.run_until_complete(task())
finally:
loop.close()
Bài viết trước đã sử dụng một ví dụ thực tế để giải thích việc sử dụng loop.run_in_executor
phương pháp của asyncio để song song hóa việc thực thi mã trong nhóm quy trình đồng thời nhận kết quả của từng quy trình con từ quy trình chính. Nếu bạn chưa đọc bài viết trước, bạn có thể xem tại đây:
Do đó, giải pháp của chúng tôi xuất hiện: phân phối nhiều tác vụ đồng thời cho nhiều quy trình con bằng cách sử dụng thực thi đa lõi thông qua phương thức loop.run_in_executor
, sau đó gọi asyncio.run
từng quy trình con để bắt đầu vòng lặp sự kiện tương ứng và thực thi mã đồng thời. Sơ đồ sau đây cho thấy toàn bộ dòng chảy:
Trong đó phần màu xanh lá cây đại diện cho các quy trình phụ mà chúng tôi đã bắt đầu. Phần màu vàng đại diện cho các tác vụ đồng thời mà chúng tôi đã bắt đầu.
Chuẩn bị trước khi bắt đầu
Mô phỏng thực hiện nhiệm vụ
Trước khi có thể giải quyết vấn đề, chúng ta cần chuẩn bị trước khi bắt đầu. Trong ví dụ này, chúng tôi không thể viết mã thực tế để thu thập nội dung trang web vì nó sẽ rất khó chịu cho trang web mục tiêu, vì vậy chúng tôi sẽ mô phỏng tác vụ thực sự của mình bằng mã:
Như mã cho thấy, trước tiên chúng tôi sử dụng asyncio.sleep
để mô phỏng việc trả lại tác vụ IO trong thời gian ngẫu nhiên và tổng kết lặp lại để mô phỏng quá trình xử lý của CPU sau khi dữ liệu được trả về.
Tác dụng của mật mã truyền thống
Tiếp theo, chúng tôi sử dụng cách tiếp cận truyền thống để bắt đầu 10.000 tác vụ đồng thời trong một phương thức chính và xem thời gian mà loạt tác vụ đồng thời này sử dụng:
Như hình minh họa, việc thực thi các tác vụ asyncio chỉ với một lõi sẽ mất nhiều thời gian hơn.
Việc thực hiện mã
Tiếp theo, hãy triển khai mã asyncio đa lõi theo sơ đồ và xem hiệu suất có được cải thiện hay không.
Thiết kế cấu trúc tổng thể của mã
Đầu tiên, với tư cách là một kiến trúc sư, trước tiên chúng ta vẫn cần xác định cấu trúc tập lệnh tổng thể, phương thức nào được yêu cầu và nhiệm vụ mà mỗi phương thức cần hoàn thành:
Việc thực hiện cụ thể của từng phương pháp
Sau đó, hãy thực hiện từng bước một.
Phương query_concurrently
thức sẽ bắt đầu đồng thời lô tác vụ được chỉ định và nhận kết quả thông qua asyncio.gather
phương thức:
Phương thức này run_batch_tasks
không phải là một phương thức không đồng bộ, vì nó được bắt đầu trực tiếp trong tiến trình con:
Cuối cùng, có main
phương pháp của chúng tôi. Phương thức này sẽ gọi loop.run_in_executor
phương thức để run_batch_tasks
thực thi phương thức trong nhóm quy trình và hợp nhất các kết quả của việc thực thi quy trình con vào một danh sách:
Vì chúng tôi đang viết một tập lệnh đa quy trình, chúng tôi cần sử dụng if __name__ == “__main__”
để bắt đầu phương thức chính trong quy trình chính:
Thực thi mã và xem kết quả
Tiếp theo, chúng tôi bắt đầu tập lệnh và xem tải trên từng lõi trong trình quản lý tác vụ:
Như bạn có thể thấy, tất cả các lõi CPU đều được tận dụng.
Cuối cùng, chúng tôi quan sát thời gian thực thi mã và xác nhận rằng mã asyncio đa luồng thực sự tăng tốc độ thực thi mã lên nhiều lần! Nhiệm vụ hoàn thành!
Phần kết luận
Trong bài viết này, tôi đã giải thích lý do tại sao asyncio có thể thực thi đồng thời các tác vụ chuyên sâu IO nhưng vẫn mất nhiều thời gian hơn dự kiến khi chạy các lô lớn tác vụ đồng thời.
Đó là bởi vì trong sơ đồ triển khai mã asyncio truyền thống, vòng lặp sự kiện chỉ có thể thực thi các tác vụ trên một lõi và các lõi khác ở trạng thái không hoạt động.
Vì vậy, tôi đã triển khai một giải pháp để bạn gọi riêng từng vòng lặp sự kiện trên nhiều lõi để thực thi song song các tác vụ đồng thời. Và cuối cùng, nó đã cải thiện hiệu suất của mã một cách đáng kể.
Do khả năng còn hạn chế nên lời giải trong bài viết này không tránh khỏi những thiếu sót. Tôi hoan nghênh ý kiến và thảo luận của bạn. Mình sẽ chủ động trả lời cho bạn.