Chia Sẻ Proto Giữa Các Vm Lua Khác Nhau - nói dối e blog

Chia Sẻ Proto Giữa Các Vm Lua Khác Nhau

Trong các hệ thống như Skynet, việc tạo hàng ngàn máy ảo Lua trong cùng một tiến trình hệ thống là điều hoàn toàn khả thi. Máy ảo Lua về bản chất có chi phí rất thấp - khi không tải bất kỳ thư viện nào (kể cả thư viện cơ bản), nó chỉ chiếm vài trăm byte. Tuy nhiên trong thực tế, chúng ta vẫn cần tải nhiều thư viện khác nhau.

Khi tải các thư viện viết bằng C vào máy ảo Lua, các prototype hàm C này chỉ tồn tại duy nhất một bản trong toàn tiến trình. Ngược lại, các thư viện viết bằng Lua lại phải được sao chép riêng biệt cho từng máy ảo. Khi có hàng ngàn máy ảo cùng chạy mã nguồn giống nhau, sự lãng phí này trở nên cực kỳ nghiêm trọng.

Chúng ta đều biết rằng trong Lua, hàm là kiểu dữ liệu hạng nhất (first-class). Lua gọi hàm là closure - thực chất là sự kết hợp giữa prototype hàm (proto) và các upvalue được liên kết với nó. Ngay cả khi không có upvalue, hàm Lua mà chúng ta thấy ở cấp độ ngôn ngữ vẫn là một closure, chỉ khác là số lượng upvalue bằng 0.

Điểm khác biệt ở đây là với hàm C: các hàm C không có upvalue được gọi là “light C function”, có thể xem như chỉ chứa prototype. Nếu các hàm có cùng logic thực thi, prototype của chúng cũng sẽ giống nhau. Dù bạn có bao nhiêu máy ảo Lua trong tiến trình, miễn là chạy cùng mã nguồn, chúng đều sử dụng chung prototype hàm. Tuy nhiên prototype hàm C có thể tồn tại duy nhất một bản trong đoạn mã của tiến trình, trong khi prototype hàm Lua do nhiều lý do phải được sao chép riêng cho từng máy ảo.

Vậy những hạn chế này là gì?

Prototype hàm chứa ba loại dữ liệu: bytecode, bảng hằng số, và thông tin gỡ lỗi (bao gồm số hiệu dòng tương ứng với bytecode, tên hàm, tên biến cục bộ…). Các dữ liệu này đều là read-only và về lý thuyết có thể được chia sẻ. Tuy nhiên prototype (proto) cũng là một kiểu dữ liệu cơ bản của Lua (dù không được phơi bày ở cấp độ ngôn ngữ), được quản lý bởi garbage collector (GC) như một gcobject. Điều này khiến nó phải tham gia vào quá trình thu gom rác của máy ảo Lua.

Trong quá trình thiết kế, Lua không tính đến khả năng chia sẻ dữ liệu giữa các máy ảo. Để thực hiện chia sẻ, bước đầu tiên là thay đổi cơ chế quản lý vòng đời của proto. Không thể để GC của từng máy ảo đơn lẻ quyết định việc giải phóng proto khi không còn tham chiếu.

Một giải pháp toàn diện là áp dụng cơ chế reference counting đa luồng cho proto, nhưng chúng ta cũng có thể đơn giản hơn bằng cách giữ tất cả proto đã dùng trong bộ nhớ, bất kể có còn tham chiếu hay không. Thực tế, cách làm này đã tồn tại phổ biến - nếu so sánh với hàm C, ngay cả khi hàm nằm trong thư viện động, chúng ta cũng không thể dễ dàng gỡ bỏ thư viện này vì có thể làm hỏng các con trỏ hàm đang được giữ bởi module khác. Ngoài ra, việc áp dụng reference counting sẽ đòi hỏi thay đổi lớn trong cách triển khai Lua do sự tồn tại của thông tin gỡ lỗi.

Bước tiếp theo là xử lý bảng hằng số. Các chuỗi hằng số đặc biệt khó chia sẻ giữa các máy ảo. Lua có cơ chế “interning” chuỗi ngắn - cùng một chuỗi ngắn trong cùng máy ảo chỉ tồn tại duy nhất một bản. Nhưng giữa các máy ảo khác nhau, các chuỗi này sẽ được coi là khác nhau. Nếu cố gắng chia sẻ chuỗi hằng số, không chỉ cần thêm loại chuỗi mới không bị GC quản lý, mà còn làm chậm tốc độ xử lý chuỗi (hiện tại Lua so sánh chuỗi ngắn bằng cách so sánh con trỏ - O(1), nếu chuỗi nằm ở máy ảo khác nhau sẽ phải so sánh từng ký tự - O(n)).

Bước thứ ba là xử lý closure cache trong mỗi proto. Các closure được tạo từ cùng proto và upvalue có thể được tái sử dụng. Nhưng khi proto được chia sẻ giữa các máy ảo, cơ chế cache này sẽ không còn hoạt động hiệu quả.

Bước cuối cùng là xử lý thông tin gỡ lỗi chứa nhiều chuỗi. Qua phân tích mã nguồn Lua, các chuỗi này được lưu trữ dưới dạng đối tượng chuỗi nội bộ, tham gia vào GC, nhưng không được truyền ra ngoài. Các API Lua chỉ trả về con trỏ C string tương ứng.

Dựa trên các vấn đề trên, chúng ta có thể bắt đầu cải tiến mã nguồn Lua. Giải pháp là tách cấu trúc proto thành hai phần: phần có thể chia sẻ và phần không thể chia sẻ. Phần không thể chia sẻ (bảng hằng số và cache) giữ nguyên cấu trúc proto cũ, đồng thời dùng con trỏ trỏ đến phần chia sẻ được. Trong cấu trúc chia sẻ cần lưu con trỏ đến máy ảo Lua thực sự sở hữu nó - chỉ máy ảo này mới có quyền giải phóng bộ nhớ. Các máy ảo khác khi GC sẽ kiểm tra con trỏ này để quyết định đánh dấu dọn dẹp.

Lua cung cấp API lua_topointer để lấy con trỏ prototype từ hàm Lua (một API không được tài liệu hóa). Chúng ta cần thêm một API để khôi phục prototype thành closure. API mới lua_clonefunction sẽ sao chép bảng hằng số của prototype vào máy ảo hiện tại và tạo các cấu trúc dữ liệu cần thiết.

Tôi đã tạo patch cho Lua 5.2.3 hỗ trợ tính năng này và tích hợp vào nhánh chính của Skynet. Để tận dụng tối đa, tôi đã sửa đổi luaL_loadfilex trong Skynet thành phiên bản an toàn đa luồng. Hàm tải file này sẽ tạo một máy ảo riêng cho mỗi tên file (vì mỗi file nguồn Lua khi tải sẽ tạo ra một hàm), lưu trữ con trỏ prototype hàm. Các lần tải file cùng tên sau đó sẽ dùng lua_clonefunction thay vì đọc file và phân tích lại.

Để hỗ trợ hot-update script Lua trên máy chủ Skynet, tôi thêm phương thức skynet.cache.clear để xóa cache. Dù vậy, mã nguồn cũ thực tế không bị xóa khỏi bộ nhớ, gây ra một số rò rỉ. Tuy nhiên lượng tiết kiệm bộ nhớ từ patch này đủ để chấp nhận nếu không hot-update quá thường xuyên.

Lợi ích từ patch này:

  1. Trong dự
0%