Mô Hình Máy Ảo Lua Đa Luồng Tuần Tự
Ltask là một thư viện có chức năng tương tự như Skynet, nhưng được thiết kế chủ yếu để hoạt động trong môi trường client. Vì ra đời sau Skynet, tôi đã thử nghiệm nhiều ý tưởng mới trên nền tảng này.
Gần đây, tôi gặp phải một yêu cầu đặc biệt: một số module C/C++ của bên thứ ba được tích hợp vào engine game của chúng tôi cung cấp các giao diện thông qua cơ chế callback. Khi nhúng các module này vào các dịch vụ của ltask, các hàm callback này không thể tận dụng đầy đủ các tính năng của ltask. Ví dụ, toàn bộ thao tác IO trong hệ thống đều được xử lý bởi một dịch vụ độc lập. Khi engine đọc file, dữ liệu có thể được tải từ xa qua mạng theo cơ chế bất đồng bộ. Tuy nhiên, các module bên thứ ba thường không thiết kế để xử lý IO bất đồng bộ, mà chỉ cung cấp các hàm callback đọc file theo kiểu đồng bộ để người dùng tự triển khai.
Vậy làm thế nào để tạm dừng luồng thực thi hiện tại trong hàm callback C và chờ đợi thao tác IO hoàn thành?
Trông qua thì việc này dường như không khả thi. Vì framework không hỗ trợ cơ chế bất đồng bộ, chúng ta không thể yield từ hàm C để tạm dừng tác vụ hiện tại. Điều này khiến dịch vụ không thể xử lý các tin nhắn bên ngoài cho đến khi callback kết thúc, làm mất khả năng tận dụng trình lập lịch của ltask để thực hiện IO bất đồng bộ.
Máy ảo Lua cũng không được thiết kế để an toàn đa luồng (và không cần thiết phải như vậy), nên chúng ta không thể chạy module C cùng các binding Lua của nó trong một môi trường luồng độc lập. Do đó, việc chặn luồng IO trong callback đồng nghĩa với việc toàn bộ dịch vụ bị treo.
Một giải pháp đơn giản là tạo một kênh giao tiếp riêng với dịch vụ IO, không đi qua cơ chế tin nhắn nội bộ của ltask. Hàm callback C có thể dùng kênh này để trao đổi dữ liệu với dịch vụ IO.
Tuy nhiên, sau khi suy nghĩ kỹ, tôi nhận ra rằng việc callback C không trả về không nhất thiết khiến toàn bộ dịch vụ bị chặn. Chỉ cần ltask hỗ trợ một chút, chúng ta vẫn có thể cho phép dịch vụ tiếp tục xử lý các tác vụ khác.
Mô hình lập lịch hiện tại hoạt động như sau: Bộ lập lịch phân bổ một luồng làm việc để chạy một đoạn nhỏ tác vụ của dịch vụ (thường là dùng lua_resume để thực thi một coroutine trong máy ảo Lua). Trong thời gian này, dịch vụ ở trạng thái bận, các luồng làm việc khác không thể truy cập nó. Điều này đảm bảo tính đơn luồng (non-reentrant) của dịch vụ.
Khi đoạn tác vụ hoàn thành mà hàng đợi tin nhắn của dịch vụ trống, bộ lập lịch sẽ chuyển dịch vụ sang trạng thái rảnh. Khi có tin nhắn mới đến, dịch vụ sẽ được đưa lại vào hàng đợi lập lịch.
Nếu chúng ta thêm một trạng thái mới cho dịch vụ: “tạm dừng trong hàm C”, thì dịch vụ vẫn có thể quay lại hàng đợi lập lịch. Từ góc nhìn của bộ lập lịch, một luồng làm việc bị đóng băng, nhưng máy ảo Lua bên trong dịch vụ vẫn ở trạng thái dừng (vì đang chờ trong hàm C). Miễn là không quay lại Lua, máy ảo vẫn an toàn để tiếp tục hoạt động. Bộ lập lịch chỉ cần hủy trạng thái bận của dịch vụ, cho phép các luồng khác xử lý nó.
Tuy nhiên, framework cần làm nhiều hơn. Vì coroutine đang chạy trong Lua VM vẫn ở trạng thái bận, chúng ta không thể dùng nó để chạy mã mới. Giải pháp là tạo một coroutine mới trong cùng máy ảo Lua, thay thế coroutine đang bận. Hàm callback C sẽ chờ trên một biến điều kiện hệ điều hành. Khi tác vụ IO hoàn thành, hệ thống sẽ khôi phục môi trường, đánh thức biến điều kiện và tiếp tục thực thi từ điểm dừng ban đầu.
Trong quy trình này, máy ảo Lua không chạy song song, nhưng lại hoạt động trong môi trường đa luồng - hai luồng hệ điều hành khác nhau chạy hai coroutine khác nhau của cùng một máy ảo. Điều này đảm bảo coroutine đang chờ trong hàm C giữ nguyên ngăn xếp gọi (call stack) của nó.
Tôi gọi mô hình này là “chạy tuần tự đa luồng máy ảo Lua”. Cần lưu ý rằng một số giả định an toàn trong môi trường đơn luồng nay không còn đúng. Ví dụ, trong môi trường đơn luồng, nếu một hàm Lua không sửa đổi trạng thái toàn cục và không yield, trạng thái đó được coi là bất biến trong suốt quá trình thực thi hàm. Nhưng với mô hình mới, hàm này có thể bị tạm dừng tại C, trong khi một luồng hệ điều hành khác chạy một coroutine khác sửa đổi trạng thái toàn cục đó.
Trong thực tiễn ban đầu, chúng tôi nhận thấy các module C/C++ nên được thiết kế an toàn đa luồng. Dù mô hình này không gây ra vấn đề song song thực sự, nhưng lại tạo ra nhiều tình huống reentrancy. Một hàm C có thể đang chạy nửa chừng thì bị tạm dừng, sau đó một luồng khác lại gọi vào chính hàm đó. Các module C không an toàn đa luồng thường không lường trước các tình huống này, đặc biệt là các framework có callback dễ gặp lỗi reentrancy nghiêm trọng.
Tính năng này đã được triển khai trong nhánh taskrun của ltask.