💡 Đội ngũ RetroLab nhận thấy rằng việc khám phá ra tính xác định tiềm ẩn trong Async Python là một bước tiến quan trọng, giúp các nhà phát triển xây dựng các hệ thống bền vững và đáng tin cậy hơn. Việc hiểu sâu sắc cơ chế hoạt động của event loop không chỉ cải thiện hiệu suất mà còn là chìa khóa để giải quyết những thách thức phức tạp trong lập trình đồng thời.

Khi phát triển thư viện thực thi bền vững (durable execution library) cho Python, đội ngũ DBOS đã đối mặt với một thách thức cơ bản: các durable workflows (quy trình công việc bền vững) cần phải có tính xác định (deterministic) để có thể phục hồi thông qua cơ chế chạy lại (replay-based recovery). Điều này đảm bảo rằng khi một quy trình bị gián đoạn, nó có thể được khởi động lại từ một điểm kiểm tra (checkpoint) và tiếp tục một cách nhất quán, như thể chưa từng có lỗi xảy ra.
Việc làm cho các workflow Async Python có tính xác định là một nhiệm vụ phức tạp, bởi vì chúng thường thực thi nhiều bước một cách đồng thời (concurrently). Ví dụ điển hình là việc khởi chạy nhiều tác vụ đồng thời và sử dụng asyncio.gather để thu thập kết quả.

Cách tiếp cận này mang lại hiệu suất vượt trội (đặc biệt khi các tác vụ bị giới hạn bởi I/O), vì workflow không cần chờ một bước hoàn thành mới bắt đầu bước tiếp theo. Tuy nhiên, chính sự đồng thời này lại khiến việc sắp xếp thứ tự các bước trở nên khó khăn. Các bước chạy cùng lúc, chồng chéo lên nhau và có thể hoàn thành theo bất kỳ thứ tự nào, dẫn đến hành vi không thể đoán trước.

Vấn đề cốt lõi là tính đồng thời đưa ra một thứ tự thực thi các bước không rõ ràng. Khi nhiều tác vụ chạy đồng thời, cách chúng xen kẽ nhau có thể thay đổi trong mỗi lần chạy. Nhưng trong quá trình phục hồi, workflow phải có khả năng chạy lại các bước đó một cách xác định, phục hồi các bước đã hoàn thành từ các điểm kiểm tra và thực thi lại các bước chưa hoàn thành. Điều này đòi hỏi một thứ tự bước được định nghĩa rõ ràng và nhất quán giữa các lần thực thi workflow.
Vậy làm thế nào chúng ta có thể đạt được cả hai điều: các workflow có thể thực thi các bước đồng thời nhưng vẫn tạo ra một thứ tự thực thi xác định, có thể được chạy lại chính xác trong quá trình phục hồi? Để làm được điều đó, chúng ta cần hiểu rõ hơn về cách event loop của Async Python hoạt động.
Cơ chế hoạt động của Async Python
Trái tim của Async Python là một event loop (vòng lặp sự kiện). Về cơ bản, đây là một luồng (single thread) chạy một bộ lập lịch (scheduler) để thực thi một hàng đợi các tác vụ. Khi bạn gọi một hàm async, nó không thực sự chạy ngay lập tức; thay vào đó, nó tạo ra một “coroutine” – một lời gọi hàm bị “đóng băng” và chưa được thực thi. Để thực sự chạy một hàm async, bạn phải hoặc await nó trực tiếp (điều này thực thi nó ngay lập tức, loại bỏ tính đồng thời) hoặc tạo một tác vụ async cho nó (sử dụng asyncio.create_task hoặc asyncio.gather), điều này sẽ lên lịch nó vào hàng đợi của event loop. Cách phổ biến nhất để chạy nhiều hàm async đồng thời là asyncio.gather, nó nhận vào một danh sách các coroutine, lên lịch từng cái dưới dạng một tác vụ, sau đó chờ tất cả chúng hoàn thành.
Ngay cả sau khi bạn lên lịch một hàm async bằng cách tạo một tác vụ cho nó, nó vẫn không thực thi ngay lập tức. Đó là vì event loop là đơn luồng (single-threaded): nó chỉ có thể chạy một tác vụ tại một thời điểm. Để một tác vụ mới chạy, tác vụ hiện tại phải trả quyền điều khiển về cho event loop bằng cách gọi await trên một cái gì đó chưa sẵn sàng. Khi các tác vụ trả quyền điều khiển, bộ lập lịch của event loop sẽ xử lý hàng đợi, chạy từng tác vụ tuần tự cho đến khi nó tự trả quyền điều khiển. Khi một thao tác được await hoàn thành, tác vụ đang chờ nó sẽ được đặt lại vào hàng đợi để tiếp tục từ nơi nó đã dừng.
Điều quan trọng là, event loop lên lịch các tác vụ mới tạo theo thứ tự FIFO (First-In, First-Out). Giả sử một danh sách các coroutine được truyền vào asyncio.gather như trong đoạn mã ví dụ trên. asyncio.gather sẽ gói mỗi coroutine vào một tác vụ, lên lịch chúng để thực thi, sau đó trả quyền điều khiển về cho event loop. Event loop sau đó sẽ lấy tác vụ được tạo từ coroutine đầu tiên được truyền vào asyncio.gather ra khỏi hàng đợi và chạy nó cho đến khi nó trả quyền điều khiển. Sau đó, event loop sẽ lấy tác vụ thứ hai, rồi thứ ba, và cứ thế tiếp tục. Thứ tự thực thi sau đó hoàn toàn không thể đoán trước và phụ thuộc vào những gì các tác vụ thực sự đang làm, nhưng các tác vụ bắt đầu theo một thứ tự xác định:

Chính nhờ nguyên tắc này mà chúng ta có thể sắp xếp các bước một cách xác định bằng cách đặt mã trước lần await đầu tiên của bước đó. DBOS đã áp dụng điều này trong decorator @Step() của mình, dùng để bao bọc việc thực thi bước. Trước khi thực hiện bất kỳ điều gì khác, và đặc biệt là bất kỳ điều gì có thể yêu cầu await, @Step() sẽ tăng và gán một ID bước từ ngữ cảnh của workflow. Bằng cách này, các ID bước được gán một cách xác định theo đúng thứ tự các bước được truyền vào asyncio.gather. Điều này đảm bảo rằng tác vụ xử lý bước một là bước một, tác vụ xử lý bước hai là bước hai, v.v.

Tóm lại, khi xây dựng các thư viện Python, việc hiểu rõ các sắc thái của asyncio và event loop là vô cùng quan trọng. Mặc dù ban đầu có vẻ không trực quan, mô hình thực thi đơn luồng thực sự dễ hiểu hơn so với các luồng song song vì các tác vụ thực thi một cách dễ đoán và chỉ có thể xen kẽ khi quyền điều khiển được trả về rõ ràng thông qua await. Điều này giúp việc viết mã đơn giản vừa đồng thời vừa an toàn trở nên khả thi.
Nguồn: Hacker News - https://www.dbos.dev/blog/async-python-is-secretly-deterministic






