Thực Thi Hàm Không Đồng Bộ
Trong kiến trúc Skynet, các dịch vụ chạy song song với nhau, nhưng logic nội tại của từng dịch vụ lại được thiết kế theo mô hình tuần tự. Nhà phát triển tự chia nhỏ công việc thành các “khoảng thời gian xử lý” (time slice), mỗi khoảng này sẽ lần lượt chạy trên các luồng làm việc khác nhau. Cách phổ biến nhất là sử dụng một máy ảo Lua cho mỗi dịch vụ, kết hợp với cơ chế coroutine để phân chia các khoảng thời gian xử lý. Nhờ đó, từ góc độ lập trình, các tác vụ dường như được thực thi liên tục mà không bị ngắt quãng.
Thiết kế này mang lại hai lợi ích then chốt:
- Tận dụng hiệu quả hiệu năng đa nhân thông qua tính song song giữa các dịch vụ
- Giảm tải độ phức tạp trong lập trình đa luồng nhờ mô hình tuần tự hóa logic nội bộ mỗi dịch vụ
Những ai từng làm việc với Skynet đều biết đến vấn đề kinh điển: Khi vô tình gọi một hàm C chạy quá lâu mà không trả về kết quả, luồng làm việc sẽ bị chiếm dụng hoàn toàn. Dịch vụ bị “treo” này không thể xử lý bất kỳ tin nhắn mới nào, tạo ra hiệu ứng domino ảnh hưởng đến toàn hệ thống. Đây thường được xem là lỗi thiết kế, và Skynet sẽ cảnh báo bằng thông báo maybe in an endless loop
khi phát hiện hiện tượng này.
Với vòng lặp vô hạn trong Lua, ta có thể dùng tín hiệu hệ thống để ngắt máy ảo đang chạy. Tuy nhiên nếu sự cố xảy ra trong hàm C, cách duy nhất là phải debug sau sự kiện. Vậy làm thế nào để xử lý những đoạn mã C dự kiến chạy lâu mà vẫn giữ được khả năng phản hồi của dịch vụ?
Trong nhiều năm, tôi cho rằng điều này không khả thi nếu muốn giữ nguyên nguyên tắc “mỗi dịch vụ chỉ chạy tuần tự” của Skynet. Nhưng gần đây, tôi nhận ra có thể phá vỡ giới hạn này một cách thông minh bằng cách xem xét lại chính các ràng buộc thiết kế.
Nguyên tắc “chạy tuần tự” thực chất chỉ yêu cầu: Mọi thao tác truy cập context của dịch vụ (bao gồm máy ảo Lua) phải được tuần tự hóa. Nếu có một hàm C chạy lâu mà không cần truy cập context, đồng thời đảm bảo an toàn luồng (thread-safe), thì hoàn toàn có thể cho phép luồng làm việc khác xử lý tin nhắn mới cho cùng dịch vụ đó.
Giải pháp đề xuất: Thêm hai hàm API skynet_yield()
và skynet_resume()
vào hệ thống. Khi gọi skynet_yield()
, dịch vụ sẽ thông báo kết thúc khoảng thời gian xử lý hiện tại. Dù luồng làm việc vẫn bị chặn ở hàm C, Skynet có thể:
- Tạm dừng phân bổ thời gian xử lý cho hàm C đang chạy
- Khởi động luồng dự phòng để bù đắp năng lực xử lý bị giảm sút
- Đưa dịch vụ trở lại hàng đợi lịch trình để xử lý tin nhắn mới
Khi hàm C hoàn tất, thay vì trả về kết quả ngay lập tức, ta gọi skynet_resume()
. Lúc này Skynet sẽ kiểm tra xem dịch vụ có đang được xử lý bởi luồng khác không. Nếu có, luồng hiện tại sẽ chờ cho đến khi xử lý xong, sau đó tiếp nhận luồng điều khiển để hoàn thành quy trình.
Cơ chế này cho phép “cắt nhỏ” khoảng thời gian xử lý ngay trong cùng một luồng làm việc, chỉ cần đảm bảo an toàn luồng giữa các đoạn yield
và resume
. Từ góc độ Lua, khi gọi hàm C tiềm ẩn chạy lâu, ta chỉ cần chèn yield
trước và resume
sau để tách phần xử lý tốn thời gian, đồng thời đảm bảo phần code này không chạm vào máy ảo Lua.
Hiệu quả đạt được tương đương việc gọi một dịch vụ hệ thống từ xa: máy ảo Lua được giải phóng để xử lý tác vụ khác, đồng thời đoạn mã C vẫn chạy trong ngữ cảnh hiện tại. Phương pháp tưởng chừng kỳ quặc này thực ra đã được áp dụng thành công trong dự án ltask của tôi.
Việc tích hợp tính năng này vào Skynet sẽ mở ra nhiều khả năng mới:
- Dịch vụ mạng có thể được triển khai như dịch vụ thường thay vì nằm trong nhân Skynet. Thay vì bị chặn hoàn toàn khi gọi
epoll_wait
, ta có thể “cắt” khoảng thời gian chờ bằngyield/resume
. - Dễ dàng tích hợp các thư viện C có API chặn (blocking API): Không cần phải viết lại phần I/O như hiện tại, có thể sử dụng trực tiếp driver cơ sở dữ liệu chính hãng.
Đây là bước tiến quan trọng trong việc cân bằng giữa hiệu năng đa nhân và tính đơn giản trong lập trình dịch vụ phân tán.