在 Unity3D 的 Mono 虛擬機中嵌入 Lua 的一個方案
很多使用 Unity3D 開發的項目,都不太喜歡 C# 這門開發語言,對于游戲開發很多人還是更喜歡 Lua 一些。而 Lua 作為一門嵌入式語言,嵌入別的宿主中正是它說擅長的事。這些年,我見過許多人都做過 U3D 的 Lua 嵌入方案。比如我公司的阿楠同學用純 C# 實現了一個 Lua 5.2 (用于在 U3D web 控件中嵌入 Lua 語言的 UniLua );還有 ulua slua wlua plua xlua ... 數不勝數。我猜測,a-z 這 26 個字母早就用完了。
上面提到的項目的作者不少是我很熟悉的朋友,我們公司現在的 U3D 游戲也由同事自己實現了一套差不多的東西。所以我曾了解過這些方案。但我一直覺得這些方案要么做的過于繁瑣,要么有些細節上不太完備,總是手癢想按自己的想法搞搞看。
Mono 和 C 通訊使用 P/Invoke ,用起來不算麻煩,但是要小心暗地里做的 Marshal 的代價,特別是對象傳遞時裝箱拆箱的成本。Lua 和 C 通訊有一套完善的 C API ,但完全正確使用并不容易。核心難點是 Mono 和 Lua 各有一套自己的異常機制,讓它們協調工作必須很小心的封裝兩個語言的邊界,不要讓異常漏出去。
我認為簡單且完備的 Mono / Lua 交互方案是這樣的:
當一邊要和另一邊通訊時,這和 C/S 結構的相互通訊并沒有本質區別,都是發送一串數據到對方虛擬機。這種抽象方式要比 Mono 和 C 交互用的 P/Invoke 或是 Lua 的一堆 C API 要簡潔的多。通常說來,一切的跨虛擬機通訊,都僅可以看成是一次異地函數調用。只要約定發送的數據串的第一項是一個函數,而后續內容是調用的參數即可。
所以 Mono 和 Lua 的交互方案就簡化成了,如何從一邊發送一串數據,這串數據中可以包含兩邊都認可的基本數據類型,如數字、字符串、布爾量,也可以包含某個虛擬機中的對象。我們并不需要真的把本地的一個對象的數據內容全部序列化成串發送給對端,而只需要給將發出的本地對象附上一個數字 id ,對端記錄下 id ,等后面真的需要操作這個遠程對象時,再將 id 發送回去即可。
要調用的函數本身也是一個本地對象。對于 Lua ,函數本來就是 first class 的,而 Mono 這邊則可以統一給一個 Delegate 來做此媒介。
以 Mono 調用 Lua 為例,我們用事先獲取到的 Lua 函數對象 id ,加上調用參數,將這一系列數據組織在一個不需要特別做 Marshal 的 struct 中,把這個 struct 通過 P/Invoke 傳給 C 層;然后 C 函數調用一個寫好的 Lua 函數把 struct 的內容置入 Lua VM 。然后在 Lua VM 中,用事先定義好的流程去處理它,通常的處理方式就是將第一個函數對象壓棧,用后面的數據做參數調用它。最后,取得函數調用的返回值,再將返回值編碼成 Mono 可操作的 struct 返回。
之所以是通過一個 struct 轉換,而不是像很多別的封裝方案那樣把 lua 的 C API 導成 C# 的 API 直接操作 Lua 虛擬機。是因為從設計層面看,我們需要提高這個模塊的內聚性,讓和 Lua 交互層和 Mono 有最少的接口(減少耦合)。另,Lua 的 API 原本是供 C 使用的,對于異常處理有一套獨特的規則;而摻入 Mono 這個東西后,我們又需要異常不外溢。把 struct 壓入 Lua 虛擬機的過程可以用唯一一個 lua 函數做到,更方便限制住任何可能產生的異常。
Lua 調用 Mono 會稍微麻煩一點,需要定義一個 Delegate ,然后再把需要調用的 C# 函數/類等都按此 Delegate 做一些封裝。好在 C# 有完善的反射機制來做這件事,若想提高效率的話,還可以有別的優化手段,比如為需要導出的類做代碼生成。因為嵌入 Lua 的目的是將多變的業務放到更靈活的 Lua 語言中去編寫,而 C# 這邊的代碼相對固定,在項目中后期基本不會有太多變化,這些優化手段都是值得在項目前期進行的。
注:這里從 Mono 返回字符串部分要小心處理。因為 Mono 向外傳遞字符串有額外的開銷,最好能做到不傳字符串時,可以沒有這個開銷。
這個周末,我花花整整一天的時間來實現上面的想法。 它可以在 mono 上編譯運行,暫時沒有文檔,但是整個結構很簡單,使用范例在 test.cs 里也基本展示出來了。
這里花去不少篇幅完成的工作是兩個不同虛擬機間的對象相互引用。
一個虛擬機的對象,如果傳遞到另一邊,需要在本地做一個強引用,防止被 gc 掉。當對方不再使用這個對象后,可以解除這個強引用。對于遠程對象,在本地都是記錄一個 id 。Lua 和 C# 都有發現一個對象不再使用的能力,Lua 利用的是弱表,C# 有 Weak Reference 。以 Lua 為例,我們將遠程對象放在弱表中,以 id 去索引;同時再把遠程對象的 id 都收集在一個集合里。只需要定期檢查 id 集合中有哪些 id 于弱表中查詢不到了,它們就是不再使用的遠程對象。
固然,還可以用 __gc 方法在遠程代理對象被回收時獲知信息,但我并不推薦這種做法。加上 __gc 方法會為 gc 流程增加許多不必要的負擔,而且這些方法的調用時機很難主動掌控,最終你還是只會在 __gc 方法中登記一下 id ,和上面提到的主動比對弱表的方案并沒有獲得任何好處。
真正難處理的地方在于兩個虛擬機間對象的循環引用。
假設 mono 中有一個對象 A 被傳遞到 Lua ,Lua 中為之生成了代理 A' ;Lua 中有另一個對象 B 傳遞給 Mono ,Mono 為之生成了代理對象 B' 。
如果 mono 中 A 引用了 B' ,同時 Lua 中 B 引用了 A' ,則造成了循環引用。由于 Lua 中的 A' 不回收的話,Mono 不能回收 A ;同理 Mono 中的 B' 不回收的話, Lua 中也會一直持有 B 的強引用。所以 A B 兩個對象即使沒有任何別的地方使用它們了,也無法被回收掉。
回收這類循環引用的對象也并非沒有辦法。如果虛擬機具備一種能力,可以獲知一個對象是否只被特定東西(在這里指外部虛擬機)引用住,那么就可以很簡單的解決這個問題。
當 Mono / Lua 發現,某些對象僅存在外部引用,那么就將這些對象設置成一個特殊狀態(可以是引用次數加一,也可以是放在一個特殊集合中);一旦某個對象被設置了兩次特殊狀態(雙方都不再引用),就可以真的清除它們。
我對 C# 不太熟悉,不知道如何做到這點;但 Lua 做這件事情非常容易。
一種方法是,自己遍歷虛擬機,但不遍歷導出對象的集合,所有沒有遍歷到的,但存在于這個集合中的對象,就是僅有外部引用的。遍歷虛擬機對 Lua 來說不是難事,我在兩個過去的項目中分別用Lua 和C 各實現過一遍。
還有一種取巧的方法需要利用 Lua 的ephemeron table 。當我們需要檢測一個對象是否只有外部引用時,可以先把它從引用表里移除,移到一個 ephemeron table 中。這個 table 的結構是 obj : { obj } 這個樣子。對于 { obj } 這個 value 可以加上 __gc 方法。如果 obj 沒有額外的引用,那么 __gc 會被調用。我們可以把 obj 移到另一個叫做墳場的 table 中復活。這樣 obj 就沒有真的被清理掉了。
不過采用這個方法時,要特別留意 weak table (ephemeron table) 在工作時,會讓 暫時移除的 obj 處于一種中間狀態 ,即不在 weak table 中, __gc 也還沒有被調用,也就是沒來得及移到墳場。
僅使用 Lua 這種檢測能力,就足以消除循環引用。當我們找到只有外部引用的對象,就可以認為在當次 gc 循環結束后,這批對象沒有內部引用了,它們只有外部引用,且相互間可能有聯系(即前面說的, A B 間有循環引用)。
這批對象暫時不能從 Lua 中刪除,因為 C# 一側可能還持有它們的引用,日后會訪問它們。但 Lua 中目前已經沒有引用了,可以把這些對象的刪除請求發送給 Mono 。Mono 收到后,可以解除這批對象的外部引用(解開循環引用),等待 GC 工作;如果其中有對象真的被回收,再通知 Lua 真的刪除掉。如果 C# 還在繼續引用,則通知 Lua 把對象全部從墳場取回。
方案細節在前面給出的 issue 中已經討論的足夠多了,這里不再展開。
我們真的需要這么細致的管理雙向引用么?
在我們自己的項目中,并沒有做這些復雜處理。這是因為,一旦在 C# 中加入 Lua ,就暗示著把業務邏輯搬到了 Lua 中寫。在 Mono 和 Lua 兩邊都存在業務邏輯且交叉引用的情況本身就是很不合理的。更多的情況是,Mono 負責和引擎底層溝通,所有的引擎對象都是由 Lua 通過中間城命令 C# 去創建的;當 Lua 層不再使用這些對象后,再通知刪除。C# 本身并沒有業務層去引用這些對象。Lua 和 C# 應該是應該上下層清晰的關系,而不應該是混雜在一起的并列關系。
所以我推薦的做法是,只有 Lua 可以長期持有 Mono 中的 C# 對象,而 Mono 中只可以短期持有 Lua 層的對象(不超過游戲中的一幀)。這樣,Lua 就有權利主動清理那些自己并不持有的本地對象而不需要通知 Mono 了,這種單邊關系便不會產生循環引用。
Mono 中唯一可能長期持有的 Lua 對象唯有一些重要的回調函數,比如在每個游戲邏輯幀內都去調用一次 Lua 里定義好的 update 函數。而這種 Lua 函數對象,只需要讓 Lua 自己長期保有引用(比如放在全局表里)就可以了。
即使真的想做出一套完備的 Mono 和 Lua 間的對象雙向引用關系,我也推薦用最簡單的方案,基礎方案中不去考慮循環引用的問題。而可以單獨寫一個模塊來解開潛在的循環引用,這個模塊性能不是主要考慮問題,在合適的時候(比如 loading 場景時)啟動檢查即可。
最后簡單說說我周末實現的這套 sharplua 。它提供了在 Mono 中創建出一個 Lua 虛擬機,并可以從 C# 調用 Lua 函數,獲取返回值的能力。同時,Lua 代碼中也可以調用由 C# 注入的 C# 函數。
SharpLua 類即對應一個 lua 5.3 虛擬機,需要傳入第一個 lua 文件名啟動它。這個 lua 文件中必須 require "sharplua" 這個模塊,輔助完成初始化工作。sharplua 這個 lua 模塊中有部分是用來管理 mono 和 lua 間數據交換的內部函數,供底層工作時使用;還有一些提供給 lua 業務層使用的 api ,方便回調 C# 函數。
C# 這邊只有三個 API 用來和 Lua 通訊。
可以通過 SharpLua.GetFunction 從 Lua 虛擬機的全局表中獲得一個以字符串命名的全局函數。這是一切邏輯的起點。之所以不提供更多的獲取 Lua 內部數據的 C# API 是因為,其他的需求都可以通過你自己寫一個 Lua 全局函數來完成,C# 只需要調用它就可以了。
SharpLua.CallFunction 可以用來調用一個 Lua 函數,攜帶任意參數,可獲得任意返回值。為了實現簡單,這里限制了一次函數調用最多傳 255 個參數,返回值不能超過 256 個。
注意,返回值也可以是一個 Lua 函數對象。所以你可以寫一個 Lua 全局函數來返回 Lua 虛擬機中的其它函數。而參數則可以是任意對象,除了數字、字符串等這些 Mono 和 Lua 都有的基本類型外,還可以傳入之前的獲取的 Lua 對象以及 C# 的任意 Class 對象。這里約定了一種指定的 Delegate ,一旦把它傳個 Lua ,Lua 可以通過 sharplua.call 來回調它,從而可以做到 Lua 向 C# 通訊。具體用法可以參考 test.cs ,雖然這里是手寫了一個 Delegate 供 Lua 調用,但是你可以繼續完善它,比如使用 C# 的反射能力去間接調用任何你想調用的 C# 函數,也可以為 C# 類做一些代碼生成工作,生成函數以這個 Delegate 的形式注入 Lua 。
最后一個 API 是 SharpLua.CollectGarbage 。它會從 Lua 虛擬機中收集那些曾經傳給 Lua 的 C# 對象中,哪些 Lua 已經不再使用,好讓 Mono 這邊可以解除引用讓 Mono 的 GC 可以正確工作以回收掉它們。
SharpLua 它整個實現簡單易讀,對外接口也很少。稍加封裝,就可以嵌入 Unity3D 中使用。如果有同學有興趣繼續完善,歡迎提 PR 。
有幾點是可以繼續做的。
-
C# 的字符串最好能 marshal 成 Unicode ,然后在 Lua 里轉換成 utf8 ;還有相關的反向處理。
-
在 marshal 字符串的時候,如果發現是短字符串,可以在 mono 和 lua 間同步一張不太大的字符串表,只在第一次傳遞的時候對 string 做 marshal ,之后相同的字符串都查表傳 id ,減輕 string 傳遞的負擔。
來自:http://blog.codingnow.com/2017/01/unity3d_sharplua.html