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相关工具的安装:
1 2 3 4 5 | 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 |
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的错误:
1 2 3 4 5 6 7 | 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/liblibm.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版本就可以正常编译了。
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#使用,修改了一下最后的接口导出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | 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代码中仿照例子编写一个初步的内存查看函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | -- 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情况。我们主要针对两块内容进行了初步检查:
- 角色在场景内只做移动等简单操作,查看是否有网络、游戏简单的tick逻辑导致的内存分配。这种情况下更多是不进行手动GC,着重检查不必要的内存分配。
- 进出战斗之后查看前后快照的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文件中即可:
1 2 3 4 5 | luaprofiler/stack.c luaprofiler/clocks.c luaprofiler/function_meter.c luaprofiler/core_profiler.c luaprofiler/lua50_profiler.c |
编译也较为顺利,但是一旦在游戏中开启之后,ToLua#就会一直报错。对于Lua调用C#的接口,都会报错在这个地方:
1 2 3 4 5 6 7 8 9 | 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#的报错。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | /* 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由于在运行时会记录很多数据从而导致严重影响游戏的帧率,最后统计的结果也没有调用关系的内容,届时再在博客中和大家分享。