Cách Truyền Dữ Liệu Giữa Các Module C Trong Lua - nói dối e blog

Cách Truyền Dữ Liệu Giữa Các Module C Trong Lua

Lua có hệ thống kiểu dữ liệu tương đối hạn chế, đồng thời hệ sinh thái các module C mở rộng cho Lua cũng thiếu tính thống nhất. Điều này khiến việc truyền các khối bộ nhớ giữa các module trở thành một vấn đề nan giải.

Phương pháp phổ biến nhất là sử dụng kiểu string dựng sẵn của Lua để biểu diễn khối nhớ. Ví dụ điển hình là thư viện I/O gốc của Lua - các hàm đọc file đều trả về chuỗi. Tuy nhiên cách này gây ra chi phí sao chép bộ nhớ không cần thiết. Khi bạn xây dựng một chương trình xử lý file bằng Lua, dù các hàm xử lý có được viết bằng C đi chăng nữa, hàng loạt chuỗi tạm thời vẫn sẽ bị tạo ra và tiêu tốn tài nguyên.

Trong động cơ trò chơi của chúng tôi, chúng tôi đã tự xây dựng một thư viện I/O riêng để giảm thiểu việc sao chép chuỗi không mong muốn này. Ví dụ khi đọc file kết cấu (texture), mô hình (model), vật liệu (material), dữ liệu sẽ được chuyển trực tiếp thành các handle dùng trong rendering mà không cần lưu trữ trong máy ảo Lua. Tuy nhiên, phần đọc file và xử lý tài nguyên (ví dụ tạo kết cấu) lại thuộc hai module C khác nhau, đòi hỏi một giao thức trao đổi bộ nhớ hiệu quả.

Chúng tôi không muốn mọi module C đều phụ thuộc vào một kiểu userdata tùy chỉnh chung. Ví dụ module binding bgfx cho Lua là một thư viện tổng quát, không chỉ dùng riêng cho động cơ trò chơi của chúng tôi. Việc đưa vào một kiểu userdata đặc thù sẽ làm giảm tính linh hoạt này.

Vì vậy, tôi thiên về việc thiết lập một giao thức trao đổi dữ liệu chung thay vì bắt buộc mọi module cùng phụ thuộc một thư viện cụ thể.

Việc dùng string để truyền khối nhớ rõ ràng là giao thức phổ biến nhất, nhưng nhược điểm là hiệu suất kém do phải sao chép bộ nhớ và tạo ra nhiều đối tượng cần garbage collection dọn dẹp.

Từ lâu, chúng tôi đã bổ sung hỗ trợ raw userdata cho hầu hết các thư viện C: coi các userdata không có metatable như chuỗi thông thường. Trong cách hiện thực bên trong Lua, userdata và string rất tương đồng - đều biểu diễn được khối nhớ có độ dài xác định. Điểm khác biệt nằm ở tính bất biến của string so với khả năng chỉnh sửa của userdata.

Trong nhiều thư viện C tự viết, tôi còn đưa vào giao thức thứ ba: dùng cặp lightuserdata + integer để biểu diễn địa chỉ và độ dài bộ nhớ. Ví dụ thư viện C của skynet hỗ trợ giao thức này. Tuy nhiên có hai vấn đề: (1) tham số tăng lên thành hai, gây bất tiện so với string/userdata chỉ cần một tham số; (2) không quản lý được vòng đời của lightuserdata.

Để giải quyết vấn đề vòng đời, khi làm binding bgfx cho Lua, tôi đã cải tiến giao thức thứ ba: cho phép thêm vào một tham số lifetime object sau địa chỉ và độ dài. Nếu cần quản lý vòng đời, phía Lua sẽ giữ tham chiếu đến object này, và khi không dùng địa chỉ nữa thì giải phóng tham chiếu. Khi lifetime object là string, chúng ta có thể dùng lightuserdata trỏ đến một đoạn con trong chuỗi mà không cần tạo chuỗi mới; nếu là table/userdata có phương thức gc, chúng sẽ tự giải phóng bộ nhớ khi không còn được tham chiếu.

Gần đây, chúng tôi lại phải xem xét lại vấn đề này do thay đổi kiến trúc:

Trước đây, mỗi thread (máy ảo độc lập) đều thực hiện I/O riêng. Thư viện I/O tự viết có thể dùng giao thức thứ hai trả về userdata cho các module khác sử dụng. Nay chúng tôi muốn chuyển toàn bộ I/O sang một thread chuyên dụng duy nhất - thread này đọc dữ liệu xong sẽ truyền cho các thread yêu cầu. Điều này đòi hỏi khả năng truyền dữ liệu giữa các máy ảo.

Trong giao thức thứ hai, raw userdata phải được tạo và sử dụng trong cùng một máy ảo, không thể nhận dữ liệu từ bên ngoài. Còn giao thức thứ ba (chưa từng dùng trong động cơ hiện tại) lại chưa giải quyết tốt vấn đề đầu tiên: việc dùng nhiều tham số làm giao thức trở nên phức tạp hơn so với string/userdata.

Sau nhiều cân nhắc, tôi quyết định đề xuất giao thức thứ tư: dùng một đối tượng Lua duy nhất để chứa ba thông tin: địa chỉ bộ nhớ, độ dài và quản lý vòng đời.

Nói đơn giản, chúng ta cần một tuple chứa ba giá trị này. Trong Lua có ba cách biểu diễn tuple: table (array), cặp userdata + uservalue, hoặc function closure. Vì raw userdata đã được dùng trong giao thức thứ hai, tôi không muốn gây xung đột, nên chỉ còn lại table và function để lựa chọn. Theo tôi, function là lựa chọn tối ưu.

Khi truyền một function, chúng ta có thể gọi nó bằng lua_call(L, 3, 0); để nhận về bộ ba giá trị. Hai giá trị đầu là địa chỉ bộ nhớ (lightuserdata) và độ dài (integer); giá trị thứ ba là tùy chọn, dùng để quản lý vòng đời. Đặc biệt, nếu giá trị thứ ba này cũng là một function, chúng ta có thể gọi nó ngay sau khi sử dụng xong bộ nhớ để giải phóng tham chiếu; nếu không, sẽ dựa vào garbage collection của object này như trong giao thức thứ ba.

Lợi thế của giao thức mới:

  • Tính linh hoạt: Hỗ trợ nhiều cách quản lý vòng đời (chuỗi, userdata có gc, function callback).
  • Hiệu suất: Tránh sao chép bộ nhớ không cần thiết.
  • Tương thích: Dễ tích hợp với các module hiện có mà không cần thay đổi kiến trúc.
  • Vượt qua giới hạn máy ảo: Cho phép truyền dữ liệu giữa các thread/máy ảo khác nhau.

Giải pháp này hiện đang được chúng tôi thử nghiệm trong phiên bản mới của động cơ trò chơi, hứa hẹn cải thiện đáng kể hiệu suất xử lý tài nguyên trong môi trường đa luồng.

0%