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

0. 前言

有段時間沒有寫博客了,主要原因是事情有點多,一件接著一件,沒有太多整理總結的機會。游戲開發逐漸進入鋪量制作的忙碌階段,趣味性沒那么多,新鮮感也少了,雖然還是有很多可供記錄的點,但大多比較瑣碎,難成系統,又或者可能暫時沒有結果,不便于分享。
這幾天花了一些時間在Lua層的內存檢查和性能優化與檢查方面,對比并嘗試集成了一些方案,也踩了一些坑,整理記錄在這里,給需要的同學提供參考。

1. ToLua#的編譯

之前的博客有提到過,我們使用的是ToLua#作為Unity引擎和Lua之間的橋接工具,本文記錄的集成工具都是在C層進行的,因此要編譯自己的ToLua#。
ToLua#的源碼地址是:https://github.com/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 = http://mirrors.ustc.edu.cn/msys2/REPOS/MINGW/i686
    編輯 /etc/pacman.d/mirrorlist.mingw64 ,在文件開頭添加:
    Server = http://mirrors.ustc.edu.cn/msys2/REPOS/MINGW/x86_64
    編輯 /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 
    pacman -S mingw-w64-x86_64-gcc 
    pacman -S mingw-w64-i686-make 
    pacman -S mingw-w64-x86_64-make
    pacman -S make 
  1. 安裝完畢之后,執行tolua_runtime下的對應sh文件進行編譯。

  2. 編譯Android版本需要安裝Android SDK,下載Android NDK r10e,并配置Android NDK r10e的目錄到PATH環境變量中,配置ANDROID_NDK_PATH環境變量。需要注意幾個配置:
    sh文件里的NDKABI變量,定義了NDK的版本,在msys64\etc\profiles里設置環境變量。

  3. 如果你使用的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 incompatible
F:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/lib/libm.a when searching for -lm
F:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: skipping incompatible
F:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/lib\libm.a when searching for -lm
F:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: skipping incompatible
F:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/lib/libm.a when searching for -lm
F:/msys64/mingw64/bin/../lib/gcc/x86_64-w64-mingw32/6.2.0/../../../../x86_64-w64-mingw32/bin/ld.exe: cannot find -lm

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

  1. 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[] = {
    { "snapshot",   b_snapshot },
    { NULL, NULL }
};

LUALIB_API int luaopen_snapshot(lua_State *L) {
    luaL_checkversion(L);
    #if LUA_VERSION_NUM < 502
        luaL_register(L, "snapshot", snapshot_funcs);
    #else
        luaL_newlib(L, snapshot_funcs);
    #endif
    return 1;
}

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

-- Lua內存記錄功能
local preLuaSnapshot = nil
local function snapshotLuaMemory(sender, menu, value)
    -- 首先統計Lua內存占用的情況
    print("GC前, Lua內存為:", collectgarbage("count"))
    -- collectgarbage()
    -- print("GC后, Lua內存為:", collectgarbage("count"))

    local snapshot = require "snapshot"
    local curLuaSnapshot = snapshot.snapshot()
    local ret = {}
    local count = 0
    if preLuaSnapshot ~= nil then
        for k,v in pairs(curLuaSnapshot) do
            if preLuaSnapshot[k] == nil then
                count = count + 1
                ret[k] = v
            end
        end
    end

    for k, v in pairs(ret) do
        print(k)
        print(v)
    end

    print ("Lua snapshot diff object count is " .. count)
    preLuaSnapshot = curLuaSnapshot

end

使用方法非常簡單,制作了一個按鈕,觸發上述的函數,點擊一次會做一個內存快照記錄在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 \
    luaprofiler/clocks.c \
    luaprofiler/function_meter.c \
    luaprofiler/core_profiler.c \
    luaprofiler/lua50_profiler.c \

編譯也較為順利,但是一旦在游戲中開啟之后,ToLua#就會一直報錯。對于Lua調用C#的接口,都會報錯在這個地方:

public static void CheckArgsCount(IntPtr L, int count)
{
    int c = LuaDLL.lua_gettop(L);

    if (c != count)
    {
        throw new LuaException(string.Format("no overload for method takes '{0}' arguments", c));
    }
}  

添加斷點看了下,這里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);

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

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

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

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

/* called by Lua (via the callhook mechanism) */
static void callhook(lua_State *L, lua_Debug *ar) {
  int currentline;
  lua_Debug previous_ar;
  lprofP_STATE* S;
  lua_pushlightuserdata(L, &profstate_id);
  lua_gettable(L, LUA_REGISTRYINDEX);
  S = (lprofP_STATE*)lua_touserdata(L, -1);

  if (lua_getstack(L, 1, &previous_ar) == 0) {
    currentline = -1;
  } else {
    lua_getinfo(L, "l", &previous_ar);
    currentline = previous_ar.currentline;
  }
      
  lua_getinfo(L, "nS", ar);

  if (!ar->event) {
    /* entering a function */
    lprofP_callhookIN(S, (char *)ar->name,
              (char *)ar->source, ar->linedefined,
              currentline);
  }
  else { /* ar->event == "return" */
    lprofP_callhookOUT(S);
  }
  lua_pop (L, 1); /* lua_gettable operation left a value in the lua stack, which makes the tolua param check failed! */
}

我依然有些擔心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日于杭州家中

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • 第一篇 語言 第0章 序言 Lua僅讓你用少量的代碼解決關鍵問題。 Lua所提供的機制是C不擅長的:高級語言,動態...
    testfor閱讀 2,719評論 1 7
  • 小學班上五名女生,今年兩個即將成人妻,而我還是單身狗,看著她們各自有自己得一片天,真的好失落,感覺自己就是最窩囊廢...
    d8586dbbb6b6閱讀 237評論 0 0
  • 作為一個從不化妝的姑娘,我從來沒有認為化妝的姑娘作,也不認為有保養皮膚習慣的男孩子娘,但是為什么有很多化妝的姑娘和...
    九月余馨閱讀 395評論 0 0
  • (一)下午 一張數學試卷,寫了兩個小時,邊寫邊說頭疼,到處晃蕩。開始以為娃生病了,后來發現是題量比較大,要專注用腦...
    碎碎妖閱讀 636評論 5 10
  • 身份的落差更讓人珍惜學習的機會。寫作創造了一個純粹的世界,生命需要一種濃烈度,只有寫作可以給予。也就是說不求后果,...
    靚小寶閱讀 423評論 2 2