无标题
Hỗ trợ đa luồng trong Lua
Hỗ trợ đa luồng trong Lua
Lua hỗ trợ đa luồng như thế nào?
Một máy ảo Lua đơn lẻ chỉ có thể chạy trên duy nhất một luồng (thread). Nếu muốn sử dụng Lua để xử lý song song trong cùng một tiến trình, bạn bắt buộc phải triển khai nhiều máy ảo Lua độc lập cho từng luồng riêng biệt.
Lưu ý: Trong môi trường ứng dụng đa luồng quy mô nhỏ, việc sử dụng cơ chế khóa (lock) cũng khả thi. Bạn hoàn toàn có thể tùy biến các hàm lua_lock(L)
và lua_unlock(L)
để gọi hàm khóa của hệ điều hành khi biên dịch.
Các thư viện đa luồng nổi bật cho Lua có thể kể đến Lanes và Effil. Cả hai đều cố gắng che giấu sự phức tạp của việc quản lý nhiều máy ảo, tạo cảm giác như chỉ có một máy ảo duy nhất đang chạy đa luồng. Ví dụ, Effil dùng effil.table
để mô phỏng bảng (table) và cho phép chia sẻ dữ liệu giữa các máy ảo; Lanes lại có kiểu deep userdata
để trao đổi dữ liệu giữa các luồng.
Những giải pháp này còn hỗ trợ việc gọi hàm qua lại giữa các máy ảo khác nhau, thường dựa trên cơ chế đồng bộ bytecode và upvalue giữa các máy ảo. Nhờ đó, lập trình viên có thể viết mã đa luồng bằng Lua gần giống như với các ngôn ngữ hỗ trợ đa luồng gốc (ví dụ: Golang).
Tuy nhiên, cá nhân tôi không ưa thích kiểu đa luồng này. Tôi cho rằng đa luồng vốn đã phức tạp, việc giấu đi logic song song và khuyến khích lập trình dựa trên trạng thái chia sẻ (shared state) sẽ dẫn đến nhiều vấn đề. Khi đọc mã nguồn, nếu không thể nhanh chóng xác định được một hàm đang chạy ở luồng nào, chi phí bảo trì hệ thống sẽ tăng lên đáng kể.
Vì vậy, Skynet chọn cách để khuôn khổ (framework) quản lý đa luồng, đồng thời bắt buộc người dùng phải hiểu rõ khái niệm máy ảo độc lập và mối liên hệ giữa chúng qua các luồng. Toàn bộ giao tiếp giữa các luồng chỉ có thể thực hiện qua hàng đợi tin nhắn. Nhiều dự án khác như cqueues cũng áp dụng nguyên tắc này.
Gần đây, khi phát triển động cơ phía client, tôi cũng gặp phải vấn đề đa luồng. Chẳng hạn, callback ghi log của bgfx có thể xảy ra ở bất kỳ luồng nào, điều này khiến việc bọc nó thành hàm Lua trở nên bất khả thi; hoặc phần tải tài nguyên/mã nguồn qua mạng cần được tách biệt khỏi luồng logic để xử lý IO hiệu quả hơn…
Ban đầu, tôi thử dùng Lanes nhưng phát hiện ra việc lạm dụng đa luồng dễ dẫn đến lỗi tiềm ẩn. Gần đây, tôi quyết định loại bỏ Lanes và tự viết một thư viện luồng tối giản. Vì số lượng luồng trong ứng dụng client tương đối cố định (chỉ gồm luồng render, luồng logic, luồng IO, luồng gỡ lỗi Lua, luồng vật lý…), tôi chỉ cần thiết lập các kênh giao tiếp cố định giữa chúng là đủ.
Tôi bắt đầu từ API cơ bản nhất để tạo luồng, rồi dần bổ sung tính năng theo nhu cầu. Ban đầu, chỉ cần hỗ trợ tạo luồng và truyền nhận các kiểu dữ liệu cơ bản của Lua qua kênh giao tiếp. Phần này đã được kiểm chứng rất ổn định trong Skynet, chỉ cần chuyển mã nguồn sang là dùng được ngay.
Với đặc thù client đồ họa có chu kỳ chạy tự nhiên (render theo từng frame), tôi thậm chí không cần thiết lập timeout cho việc đọc dữ liệu. Chỉ cần định kỳ kiểm tra kênh giao tiếp có dữ liệu mới hay không. Số lượng kênh giao tiếp trong hệ thống cũng có hạn, nên không cần thiết kế như kiểu channel “first-class citizen” trong Golang với khả năng tạo/hủy tự do. Tôi chỉ cần vài kênh có tên cố định, các luồng chỉ cần thống nhất tên kênh là có thể giao tiếp.
Kết quả bất ngờ là việc xây dựng hạ tầng này lại đơn giản hơn tưởng tượng. Chỉ cần sao chép lại một số mã nguồn cũ, thêm lớp bọc Lua phù hợp, tôi đã hoàn thành trong một cuối tuần. Tuần tới, tôi sẽ dùng chúng để tái cấu trúc lại phần mã nguồn động cơ đã viết (dù vẫn còn lỗi).