Unity手游開發札記——ToLua#集成內存泄露檢查和性能檢測工具

0. 前言

有段時間沒有寫博客了,主要原因是事情有點多,一件接著一件,沒有太多整理總結的機會。遊戲開發逐漸進入鋪量製作的忙碌階段,趣味性沒那麼多,新鮮感也少了,雖然還是有很多可供記錄的點,但大多比較瑣碎,難成系統,又或者可能暫時沒有結果,不便於分享。

這幾天花了一些時間在Lua層的內存檢查和性能優化與檢查方面,對比並嘗試集成了一些方案,也踩了一些坑,整理記錄在這裡,給需要的同學提供參考。

1. ToLua#的編譯

之前的博客有提到過,我們使用的是ToLua#作為Unity引擎和Lua之間的橋接工具,本文記錄的集成工具都是在C層進行的,因此要編譯自己的ToLua#。

ToLua#的源碼地址是:topameng/tolua_runtime,編譯流程可以參考其wiki文檔,不過這部分的過程記錄的不太詳細,本部分基於wiki文檔和自己在Windows以及Mac OS上的編譯過程進行一些整理,記錄整個過程和遇到的問題如下:

1) 安裝msys2-x86_64-20161025.exe工具,Web地址:http://msys2.github.io/。

2) 為msys2安裝gcc,由於原始的下載地址我本地下載非常慢而且出錯,建議添加國內的鏡像地址:

編輯 /etc/pacman.d/mirrorlist.mingw32 ,在文件開頭添加:Server = mirrors.ustc.edu.cn/msy

編輯 /etc/pacman.d/mirrorlist.mingw64 ,在文件開頭添加:

Server = mirrors.ustc.edu.cn/msy

編輯 /etc/pacman.d/mirrorlist.msys ,在文件開頭添加:

Server = http://mirrors.ustc.edu.cn/msys2/REPOS/MSYS2/$arch

然後執行 pacman -Sy 刷新軟體包數據即可。

3) 打開mingw的控制台,輸入如下命令進行gcc相關工具的安裝:

pacman -S mingw-w64-i686-gcc n pacman -S mingw-w64-x86_64-gcc n pacman -S mingw-w64-i686-make n pacman -S mingw-w64-x86_64-maken pacman -S maken

4) 安裝完畢之後,執行tolua_runtime下的對應sh文件進行編譯。

5) 編譯Android版本需要安裝Android SDK,下載Android NDK r10e,並配置Android NDK r10e的目錄到PATH環境變數中,配置ANDROID_NDK_PATH環境變數。需要注意幾個配置:

sh文件里的NDKABI變數,定義了NDK的版本,在msys64etcprofiles里設置環境變數。

6) 如果你使用的MinGW-w64 Win64 Shell來編譯32位版本的時候會報找不到dll的錯誤:

F:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: skipping incompatiblenF:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/lib/libm.a when searching for -lmnF:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: skipping incompatiblenF:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/liblibm.a when searching for -lmnF:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: skipping incompatiblenF:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/lib/libm.a when searching for -lmnF:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: cannot find -lmn

我糾結了半天,按照路徑檢查發現它用的還是64位的庫,在msys64下發現有兩個exe,一個叫做mingw64.exe,一個叫做mingw32.exe,使用32位的那個來編譯對應的32版本就可以正常編譯了。

7) iOS的編譯腳本里設置了 ISDKVER=iPhoneOS10.2.sdk,這裡要跟隨SDK的版本升級進行更新,否則LuaJit就編譯不過,報錯信息為"string.h"文件找不到。

這樣,使用不同的編譯腳本就可以編譯出對應平台的ToLua.dll文件了,拷貝文件覆蓋之前Unity的Plugins目錄下對應平台的dll文件即可實現ToLua#的更新。

注意: 在覆蓋的時候要關閉對應工程的Unity進程,否則會提示dll被佔用無法覆蓋。

2. 內存檢查工具

Unity引擎中有自己的內存檢查工具,但是無法查看集成的Lua部分的內存情況。Lua的內存管理由Lua虛擬機負責,Lua 5.1版本的垃圾回收使用的是雙白色標記清除(Mark-sweep)演算法,5.2版本引入了分代的策略,具體的實現原理可以參考Lua的源代碼。從根本上說,由於有垃圾回收功能的存在,即使存在循環引用的情況,也可以在GC的過程中對不再使用的內存進行釋放,不存在嚴格意義上的「內存泄露」,然而,在遊戲運行過程中,無論是C#層的頻繁GC還是Lua層的頻繁GC,都會導致卡頓的問題,因此要盡量減少內存的無謂分配,從而減少GC的執行頻率。當然,由於開發過程中存在C#和Lua的互相引用,可能會出現由於釋放過程存在問題導致C#和Lua的對象互相引用然後都GC不掉的情況,這個可能產生更加嚴重的內存問題。因此,我們需要的內存檢查工具最少應當可以針對上述這兩種情況進行檢查。

通常進行內存排查的原理比較相似,大都是基於兩份內存快照之間的差異來進行人工的對比和分析,對於Lua 5.1來說,大部分的資源都是在_G這樣一個變數,因此一次常見的思路是從這個_G開始來遍歷出所有的Lua對象,當然,如果不想遺漏數據,更加好的遍歷起始應當是從debug.getregistry()開始。編寫的代碼不太複雜,逐一處理好metatable等相關的內容即可,我嘗試了git上一個在Lua層的工具:lua_memkeak,有一些問題,原因是我們自己在Lua層Hook了_G的訪問機制來避免不小心寫出的全局變數。(多說幾句,在Lua中不聲明local的變數都會作為全局變數,或者更嚴格地說,函數中的變數在不聲明local的情況下,會被放在函數的env中,只是默認所有函數的env都是_G,所以才造成了不聲明local的變數會被放置在_G中的現象。不經意的全局變數可能會導致意料之外的數據修改從而產生難以排查的bug,同事導致部分內存無法被正確地釋放,因此我們項目中Lua的所有全局變數必須由一個函數來進行聲明。)

因此我更傾向於找一個C層的實現,雲風作為Lua的倡導者,在他的博客中提供了一個Lua內存分析工具:Snapshot,對應的Git地址在這裡。集成到ToLua#中的過程也比較簡單,把snapshot.c文件拷貝到ToLua_Runtime目錄下,修改一下build腳本,將snapshot.c加入到編譯代碼中。由於原始的snapshot.c文件目標是編譯為dll供Lua虛擬機調用,這裡為了方便ToLua#使用,修改了一下最後的介面導出:

static const struct luaL_Reg snapshot_funcs[] = {n { "snapshot", b_snapshot },n { NULL, NULL }n};nnLUALIB_API int luaopen_snapshot(lua_State *L) {n luaL_checkversion(L);n #if LUA_VERSION_NUM < 502n luaL_register(L, "snapshot", snapshot_funcs);n #elsen luaL_newlib(L, snapshot_funcs);n #endifn return 1;n}n

按照第一步重新編譯ToLua#的dll文件,更新之後,添加對應導出的C#介面,然後在Lua代碼中仿照例子編寫一個初步的內存查看函數:

-- Lua內存記錄功能nlocal preLuaSnapshot = nilnlocal function snapshotLuaMemory(sender, menu, value)n -- 首先統計Lua內存佔用的情況n print("GC前, Lua內存為:", collectgarbage("count"))n -- collectgarbage()n -- print("GC後, Lua內存為:", collectgarbage("count"))nn local snapshot = require "snapshot"n local curLuaSnapshot = snapshot.snapshot()n local ret = {}n local count = 0n if preLuaSnapshot ~= nil thenn for k,v in pairs(curLuaSnapshot) don if preLuaSnapshot[k] == nil thenn count = count + 1n ret[k] = vn endn endn endnn for k, v in pairs(ret) don print(k)n print(v)n endnn print ("Lua snapshot diff object count is " .. count)n preLuaSnapshot = curLuaSnapshotnnendn

使用方法非常簡單,製作了一個按鈕,觸發上述的函數,點擊一次會做一個內存快照記錄在preLuaSnapShot中,過一段時間,再點擊一次按鈕,就會在控制台輸出內存的diff情況。我們主要針對兩塊內容進行了初步檢查:

  1. 角色在場景內只做移動等簡單操作,查看是否有網路、遊戲簡單的tick邏輯導致的內存分配。這種情況下更多是不進行手動GC,著重檢查不必要的內存分配。
  2. 進出戰鬥之後查看前後快照的diff,檢查是否有內存泄露的情況。這種情況下會進行一次手動GC,來回收那些戰鬥中的臨時數據,著重檢查由於各種引用關係導致無法被釋放的內存對象。

我們初步發現了之前代碼中的一些問題,包括邏輯代碼中可以優化的table創建過程,角色移動過程中不斷的回調用的Slot對象創建,ToLua#中協程實現的時候每次wait都會創建一個Timer對象等問題,並逐一進行了修復。

注意:在使用雲風這個Snapshot工具的時候,它好用的地方是可以查看到對象的類型、變數名稱和文件行數,但是可能由於某些對象引用在ToLua#內部或者C#層,抑或是我們自己編寫的Lua Class機制,導致一些條目無法像雲風博客中說的看到那麼多細緻的內容,只能看到變數名稱和類型,通過全局搜索來判定對象被引用的位置。時間關係沒有去查看源代碼進行優化,之後有時間可以再仔細看下,如果有朋友知道如何解決也希望不吝賜教~

3. Profiler的集成

由於我們放置了大量的邏輯在Lua層,因此也需要對Lua的部分進行Profiler來定位可以進行優化的點。由於內存部分使用了雲風的Snapshot,因此自然想看看雲風的git上是否有Profiler的工具,果然很快找到了——LuaProfiler。結構也很簡單,就一個profiler.c文件需要集成,因此很開心地下載下來嘗試集成到遊戲中,但是編譯的時候各種錯誤。

仔細看了一下代碼,原來用到的很多函數都是Lua 5.2和Lua 5.3版本之後才有的函數,嘗試翻找snapshot.c中的代碼進行一些5.1版本中的實現,花費了半天時間編譯通過了但是試用了下會Crash。對於Lua的代碼部分不是非常熟悉,因此覺得再在這個地方花費時間可能是個無底洞,因此又想去找找別的方法。

Lua-users上有專門的Profiling Lua Code專題,第一個是LuaProfiler,看了下是支持5.1版本的,但是git上面上次更新是08年的事情了。。。看著有點虛,又搜羅了一圈,其他基於Lua層自己做Profiler的工具感覺對於Lua的運行可能會有比較大的性能影響,因此不太想去嘗試。最後還是覺得先試試這個接近10年前的產品。

集成的過程還算順利,以win64為例,只需要添加如下部分在sh文件中即可:

luaprofiler/stack.c n luaprofiler/clocks.c n luaprofiler/function_meter.c n luaprofiler/core_profiler.c n luaprofiler/lua50_profiler.c n

編譯也較為順利,但是一旦在遊戲中開啟之後,ToLua#就會一直報錯。對於Lua調用C#的介面,都會報錯在這個地方:

public static void CheckArgsCount(IntPtr L, int count)n{n int c = LuaDLL.lua_gettop(L);nn if (c != count)n {n throw new LuaException(string.Format("no overload for method takes {0} arguments", c));n }n}n

添加斷點看了下,這裡Lua虛擬機的堆棧中的數據c的值比期望的參數個數count大1。利用一個介面查看了下具體的參數類型和數據,前面的都正確,只是最後多一個而已。一開始的想法是LuaProfiler底層的代碼為了方便記錄數據,在每次函數調用的地方都添加了一個變數來進行數據存儲。於是我想只能通過修改ToLua#的生成代碼,讓之前嚴格的參數個數必須相等的判斷修改為大於等於就通過的判定,這樣可以避免誤報LuaException,但是仔細思考之後,覺得這樣修改太過於麻煩,讓ToLua#生成的代碼可能不夠嚴謹,於是想從C層看看有沒有修改的可能。

其實,無論是雲風的方式還是這個LuaProfiler,抑或是其他的基於Lua層的性能檢查工具,其根本原理是基於lua_sethook這樣一個功能。

lua_sethook

int lua_sethook (lua_State *L, lua_Hook f, int mask, int count);

Sets the debugging hook function.

Argument f is the hook function. mask specifies on which events the hook will be called: it is formed by a bitwise or of the constants LUA_MASKCALL, LUA_MASKRET, LUA_MASKLINE, and LUA_MASKCOUNT. The count argument is only meaningful when the mask includes LUA_MASKCOUNT. For each event, the hook is called as explained below:

The call hook: is called when the interpreter calls a function. The hook is called just after Lua enters the new function, before the function gets its arguments.

The return hook: is called when the interpreter returns from a function. The hook is called just before Lua leaves the function. You have no access to the values to be returned by the function.

The line hook: is called when the interpreter is about to start the execution of a new line of code, or when it jumps back in the code (even to the same line). (This event only happens while Lua is executing a Lua function.)

The count hook: is called after the interpreter executes every count instructions. (This event only happens while Lua is executing a Lua function.)

A hook is disabled by setting mask to zero.

雲風的方式是間隔採樣的方式,hook LUA_MASKCOUNT,按照一定的間隔進行代碼採樣,這種方式不太能精確統計每個函數的運行時間,但是對於運行的程序影響較小,從整體消耗百分比的角度分析瓶頸更加準確。

lua_sethook(cL, profiler_hook, LUA_MASKCOUNT, interval);n

LuaProfiler的方式是Hook每個函數的調用和Return邏輯,可以拿到每個函數精確的運行時間,但是這個過程中也就增加了運行消耗。這跟量子力學的理論有那麼點相似——你想要觀察對象,就會對被觀察的對象產生影響。LuaProfiler通過暫停計時的方式讓統計的時間更加準確,但是運行時的消耗無法減少。

lua_sethook(L, (lua_Hook)callhook, LUA_MASKCALL | LUA_MASKRET, 0);n

仔細閱讀了一下LuaProfiler的代碼,對於一些不太了解的函數也逐一進行了搜索,最後發現其在hook的函數處理中邏輯上並不需要在Lua的棧中添加數據,它用於記錄時間消耗的數據在自己組織的一塊內存的棧結構中。

最後發現,在callback函數中的lua_gettable操作用來獲取profile的狀態信息指針,但是把這個數據遺漏在了棧中沒有pop出來。我嘗試在最後添加了lua_pop (L, 1);操作,編譯測試之後沒有遇到問題,也解決了ToLua#的報錯。

/* called by Lua (via the callhook mechanism) */nstatic void callhook(lua_State *L, lua_Debug *ar) {n int currentline;n lua_Debug previous_ar;n lprofP_STATE* S;n lua_pushlightuserdata(L, &profstate_id);n lua_gettable(L, LUA_REGISTRYINDEX);n S = (lprofP_STATE*)lua_touserdata(L, -1);nn if (lua_getstack(L, 1, &previous_ar) == 0) {n currentline = -1;n } else {n lua_getinfo(L, "l", &previous_ar);n currentline = previous_ar.currentline;n }nn lua_getinfo(L, "nS", ar);nn if (!ar->event) {n /* entering a function */n lprofP_callhookIN(S, (char *)ar->name,n (char *)ar->source, ar->linedefined,n currentline);n }n else { /* ar->event == "return" */n lprofP_callhookOUT(S);n }n lua_pop (L, 1); /* lua_gettable operation left a value in the lua stack, which makes the tolua param check failed! */n}n

我依然有些擔心LuaProfiler的作者將這個信息遺漏在棧內是否是有意為之,只是目前這個工具能夠正常工作,我就先當作自己fix了一個不過。

這裡說一個插曲,在UWA群中我去問了一下LuaProfiler的情況,有個朋友說他們使用SLua+LuaProfiler沒有遇到問題,我還專門有去看了下SLua的Warp函數,感覺其對於參數個數的檢查和ToLua差別不大,也是基於相等來做的判定。時間關係,我沒有去嘗試在SLua中集成來進行測試,有使用的朋友可以自己試下,有結論也期望反饋給我。

集成之後的LuaProfiler的使用可以參考Using LuaProfiler的描述,簡單來說使用它提供的summary.lua,結合Excel就可以進行比較好的性能分析。使用-v參數可以統計出包括執行次數、平均時長、總時間消耗在內的更多信息。

4. 總結

要在Unity中用好Lua需要注意很多東西,腳本語言本身的性能就比靜態語言要差一些,如果寫得人不夠專業,就可能會造成很多問題,包括內存泄露和性能瓶頸。通過這幾個工具的集成,可以讓項目組的其他同學方便地進行內存檢查和性能測試,越早地抓出問題,就可以讓後續編寫的代碼更好。對於我個人來說,這也是對於Lua進行C擴展的一個入門練習,通過閱讀代碼和嘗試修改bug,了解了一些基本函數的意義和使用方法。

後續有時間,我會按照項目的需求對這兩個工具進行一些改造。目前它們在信息輸出方面還有一些缺失,LuaProfiler由於在運行時會記錄很多數據從而導致嚴重影響遊戲的幀率,最後統計的結果也沒有調用關係的內容,屆時再在博客中和大家分享。

2017年4月20日於杭州家中


推薦閱讀:

維基百科中模板和模塊有什麼區別?
【遊戲安全】看我如何通過hook攻擊LuaJIT
從零開始製作2048遊戲
為什麼很多編程語言用 end 作為區塊結束符,而放棄花括弧?
Lua性能優化—Lua內存優化

TAG:Unity游戏引擎 | Lua |