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日于杭州家中

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容

  • 第一篇 语言 第0章 序言 Lua仅让你用少量的代码解决关键问题。 Lua所提供的机制是C不擅长的:高级语言,动态...
    testfor阅读 2,651评论 1 7
  • 小学班上五名女生,今年两个即将成人妻,而我还是单身狗,看着她们各自有自己得一片天,真的好失落,感觉自己就是最窝囊废...
    d8586dbbb6b6阅读 225评论 0 0
  • 作为一个从不化妆的姑娘,我从来没有认为化妆的姑娘作,也不认为有保养皮肤习惯的男孩子娘,但是为什么有很多化妆的姑娘和...
    九月余馨阅读 327评论 0 0
  • (一)下午 一张数学试卷,写了两个小时,边写边说头疼,到处晃荡。开始以为娃生病了,后来发现是题量比较大,要专注用脑...
    碎碎妖阅读 607评论 5 10
  • 身份的落差更让人珍惜学习的机会。写作创造了一个纯粹的世界,生命需要一种浓烈度,只有写作可以给予。也就是说不求后果,...
    靚小宝阅读 392评论 2 2