找回密码
 立即注册
查看: 591|回复: 0

Unity填坑笔记——记一次“内存泄露”的排查

[复制链接]
发表于 2021-1-5 09:43 | 显示全部楼层 |阅读模式
1. 起因

游戏上线之前大约不到一周的时间,安卓和iOS包都提给渠道之后,合作方的质检部门给出了一个测试报告,说我们游戏有严重的内存泄露……
我裤子都……呃,不好意思,我包都提上去了,这个时间点你跟我说这个,早干嘛去了!而且内存部分也一直是我们关注的内容,不管是我们内部的周常测试还是定期的UWA的性能测试,在内存这块都没有发现特别明显的问题。
2. 初步处理

先初步沟通了下,内存泄露的结论是在做频繁开关ui的测试时得出的,依据是PSS内存一直在增长,而且在中低配机器上都超过了建议的阈值。沟通到这里心里稍微放松了下,因为我们为了减少UI的顿卡,针对ui做了缓存机制——对大内存设备(android设备1.5G以上,iOS设备1G以上),较为复杂的界面会做一定时长的缓存,提高近期再打开的时候的体验。这个缓存最初并没有设置上限,因为设想玩家在正常流程中,并不太会连续打开非常多界面,而到一定时间界面没再打开过就会释放掉了。而合作方的这种测试正好和我们的缓存机制冲突,因此得出内存泄露的结论也可以理解。
首先尝试跟合作方解释了一下原因,然后着手做了一下缓存个数的限定,并顺手把安卓设备上的大内存定义从1.5G提高到了2G。通过Patch更新完成之后让质检部门测试,得出结论是——有略微好转,但是依然有泄露风险。并且很好心地做了一个测试:
手动测试,针对每个界面执行30次打开和关闭操作,并且每次之后手动打点,所有的17个主要依次打开之后PSS内存的增长曲线如下图所示。
PSS内存增长曲线图
这下就有点尴尬了......
3. 复现和定位

看合作方给出的内存曲线图,问题的确比较严重,PSS内存从400M增长到600M+,虽说中间有部分降低的过程,但是整体上升的趋势还是非常明显。
这样看来,和界面缓存机制并没有特别直接的关系,之前的判断并不正确。虽然上线之前事情很多,这个还是要花时间来处理。于是先尝试复现,方法很简单,作为程序不需要手动开关界面,编写一个debug功能,针对列表中的界面模拟开关操作就好了。
PSS内存不好查看,用两种方式分别验证:
    用adb连接手机,使用adb shell dumpsys meminfo <packagename>命令来手动查看内存变化;让QA同学帮忙跑了一下UWA GOT工具的OverView测试,查看PSS曲线。
结论都是一样的,PSS内存的确存在较快的增长,应该是UI导致游戏内存泄露了。
我们平常对于内存的关注中,虽然PSS内存一直也偏高,但是没有观察到过这么明显的泄露现象,也去翻了下近期的UWA测试报告,基本PSS内存的曲线是这样的:
UWA测试中的PSS内存曲线
整体偏高,但有升有降,后半部分还是趋于平缓的。但是为什么在这种连续开关ui的极限测试下会有这么明显的泄露现象呢?
复现之后,接下来的问题就是定位具体泄露的部分是什么。因为增长的是PSS内存,所以要分别看下各个部分的内存占用变化。
首先怀疑的是贴图等资源泄露了,因为这种上百兆的内存增长,感觉资源泄露的可能比较大,同样使用UWA的GOT工具来看Assets部分的变化,在针对一个ui频繁开关的时候,并没有什么变化,使用Unity Profiler的Memory部分的Detail视图在设备上来看也是没有任何的变化的。
切换回Profiler的Simple视图来看,发现Mono有很明显的增长!整个测试做下来的话,可以从最初的30多M增长到大约160M+,这跟PSS的内存增长规模是比较契合的。也看了下日常UWA测试中Mono内存的增长曲线,并没有特别明显的泄露:
UWA测试中的Mono内存曲线
那么,现在的结论就是——
看上去UI的频繁开关会导致Mono内存的泄露!
4. 工具排查

定位了泄露的大头是Mono内存之后,接下来就是要检查具体的泄露内容是什么。设计了一个简单的测试用例,针对单个ui开关多次,查看前后的mono内存差异。
因为观察之前的mono内存会有降低的情况,说明在这个过程中会有GC的触发,但是并不能释放掉,所以感觉是真正的泄露,而不是由于没有触发到GC导致的“伪泄露”。基于这个推断,在每次测试之后都手动调用一下完整的GC逻辑,来避免可以被回收内存的干扰。
首先使用UWA的Mono工具进行排查,通过Persistent模式看到的差异数据有些复杂:
UWA Got工具看到的Mono内存驻留情况
UWA目前的功能是每隔1000帧做一次Mono内存驻留的快照,这对于长时间的测试足够了,而且对于运行性能以及内存影响比较小(最新版本的UWA GOT工具已经支持手动Sample了~~)。但是针对我们这种针对性的测试不是特别理想,看了下wetest,有手动snapshot的过程,申请了一个账号试用了下。设计了一个单ui开关多次的测试用例,并且在开始和结束做了完整的GC。
通过差异,看似乎在界面的CreateUIChild逻辑中有泄露的可能,review相关的代码,的确发现有子界面的UI在销毁逻辑中存在泄露的情况。但是这部分的泄露应该没有那么严重,修复之后再做测试,对于整体内存增长的降低只有大约个位数,说明泄露的核心部分并不是这里。
这时开始审视之前对于泄露的假设是否成立——是不是这部分内存的增长在某些情况下是可以被释放的?于是做了一个很暴力的测试——在每次ui关闭的时候,都手动调用一次完整的GC流程,包括:
    音频等其他由逻辑触发的资源释放;C#的GC:GC.Collect();释放无用资源:Resources.UnloadUnusedAssets();Lua的GC。
虽然测试的过程变得很卡,但是Mono内存是可以控制住的,整个测试下下来从之前的160M+降低到了峰值50M左右。
这就说明,泄露的大部分是可以被正常的GC回收的,只是有什么东西Hold住了它,让它无法被释放。
5. 最终定位

后续的测试因为手头有其他事情,交给的团队内的其他同事来帮忙做更加详细的排查和处理。在发现Mono的增长部分其实是可以被GC的时候,逐个测试具体是哪部分的GC可以真正释放这块内存。前面已经列举了一次完整的GC所包含的东西,逐个去掉来进行测试,最终发现是Lua的GC调用影响最大
这就说明,是由于Lua对于C#对象的引用,导致C#的GC机制无法释放掉对应的内存对象。
Lua自身是不会拿到C#的对象的,而是通过Tolua这个胶水层来处理。深入ToLua来看,会发现所有对象的引用都是由ObjectTranslator这个类来处理,其中使用了一个ObjectPool对C#对象进行存储,Lua层拿到的是一个int形式的Handler。对于Lua层拿到的对象,会重写其__gc函数,当Lua的GC执行的时候,会调用这一函数,从而释放掉ObjectTranslator这层缓存的C#对象。
  1. //……
  2. if (metaMap.TryGetValue(t, out reference))
  3. {
  4.     LuaDLL.tolua_beginclass(L, name, baseMetaRef, reference);
  5.     RegFunction("__gc", Collect);
  6. }
  7. else
  8. {
  9.     reference = LuaDLL.tolua_beginclass(L, name, baseMetaRef);
  10.     RegFunction("__gc", Collect);               
  11.     BindTypeRef(reference, t);
  12. }
复制代码
Collect函数的定义如下:
  1. public static int Collect(IntPtr L)
  2. {
  3.     int udata = LuaDLL.tolua_rawnetobj(L, 1);
  4.     if (udata != -1)
  5.     {
  6.         ObjectTranslator translator = GetTranslator(L);
  7.         translator.RemoveObject(udata);
  8.     }
  9.     return 0;
  10. }
  11. //lua gc一个对象(lua 库不再引用,但不代表c#没使用)
  12. public void RemoveObject(int udata)
  13. {
  14.     //只有lua gc才能移除
  15.     object o = objects.Remove(udata);
  16.     if (o != null)
  17.     {
  18.         if (!TypeChecker.IsValueType(o.GetType()))
  19.         {
  20.             RemoveObject(o, udata);
  21.         }
  22.         if (LogGC)
  23.         {
  24.             Debugger.Log("gc object {0}, id {1}", o, udata);
  25.         }
  26.     }
  27. }
复制代码
为了验证这部分泄露的情况,同事又在ToLua层添加了对于对象的监控,通过log diff的形式来排查是哪些对象被泄露在了这一层。最终证明的确是那些在Lua层被访问过的对象,在不调用Lua GC的情况下会一直驻留在ObjectTranslator这一层。
我们来对整个逻辑做一下梳理和回顾:
    在没有UI缓存的情况下,每创建一个ui,都会去初始化对应的prefab,并且Lua层会获取自己需要设置的那些GameObject以及Component,这时候这些对象都会在ObjectTranslator这层有记录;当UI关闭的时候,会调用GameObject.Destroy函数,将对应的C# GameObject销毁;这时候,Lua中那些对于C#对象的应用并不会销毁,因为没有调用Lua的GC,于是出现了ObjectTranslator这层依然保存着这些对象的引用的情况;由于Lua的内存增长比较慢,所以对于GC的触发非常不频繁;C#部分GC的时候,对于这些在ObjectTranslator层记录的对象,虽然它们在Unity眼中已经不再被使用了,与null的相等判定结果是true,但是作为System.Object对象,它们实际上并不是null,而且在被ObjectTranslator对象引用,无法释放占用的内存空间,这就导致了内存的增长,即使触发了C#的GC逻辑,也无法进行释放;当Lua的GC被调用过一次之后,下次C#的GC就可以释放掉这部分的对象。
说实话,这个时候我有点怀念Python的GC中引用计数的功能……
6. 解决方案

对于跨语言的系统设计,内存释放一直是要持续关注的部分。这次发现的问题并不是ToLua的bug,而是由于C#和Lua都是基于延迟清理的思路实现GC算法,再加上两边的GC无法同步进行而导致的。
虽然游戏已经上线,对于C#部分的修改也比较难提交,但是我们还是讨论和分析了一些解决方案。
1.比较理想的方式,其实是在C#触发GC的时候,先去调用一次Lua的GC,这样让两边的GC有一个同步的过程,可以多地释放掉无用的内存。但是这种方式不太好实现,貌似没找到方便监听系统触发GC的逻辑。
2.使用更高频率的Lua GC。我们之前Lua手动GC的方式是在状态改变的时候,这次针对ui开关的测试是无法触发到Lua的手动GC的,那么一种思路是按照一个间隔来手动触发Lua的GC,尽早释放掉内存。但是这个实际其实比较难找,做不好会造成莫名其妙的顿卡。
3.Tolua的作者蒙哥建议在关闭ui这样的节点,手动做一下一个小Step的GC,这样可以保证释放掉一部分内存。这个Step的参数要自己调整好,过大会在关闭ui的时候造成顿卡,过小又没办法及时释放内存。
4.在Lua中确定不再需要C#对象的时候,手动使用System.Object的Destroy函数进行释放,这个Warp出来的函数Tolua做了特殊的处理,会调用Tolua.Destroy来进行释放。这种就相当于针对这些对象放弃了自动GC的逻辑,需要手动进行释放,好处是可以精准控制,但是坏处是很繁琐,需要对于代码做大量的重构。
  1. [MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
  2. static int Destroy(IntPtr L)
  3. {
  4.         return ToLua.Destroy(L);
  5. }
  6. public static int Destroy(IntPtr L)
  7. {
  8.     int udata = LuaDLL.tolua_rawnetobj(L, 1);
  9.     ObjectTranslator translator = ObjectTranslator.Get(L);
  10.     translator.Destroy(udata);
  11.     return 0;
  12. }
复制代码
5.在C#层,做一个tick逻辑,每帧检查ObjectTranslator中的objects中的一部分对象,如果是Unity.GameObject类型的,查看其是否等于null,如果作为Unity.GameObject对象是null,而作为System.Object对象不是null,说明这个对象已经被Unity标记为销毁了,Unity.GameObject重载的==运算符让游戏逻辑认为它是空的,这时候C#对象可以提前销毁掉,因为即便Lua层想访问它,也已经会报错了。
我们目前选择的是方法5来进行内部的测试,原因是这种方式对于Lua层代码的改动最小,也能解决我们的大部分问题。当然这种方法的瑕疵是对于非Unity.GameObject类型的对象,也存在释放不及时的问题,这种方式无法解决,另外引入了一个update逻辑,也有一些额外的性能消耗。
7. 总结

这个内存泄露的问题困扰了我们大约一个多周的时间,这里记录的只是一些排查的关键步骤,对于中间的思考、讨论、对比等等细节无法完整地记录。由于项目临近上线,而合作方给予的测试用例也是一种比较极限的情况,所以最终线上的版本没有修复这个问题。正常进行游戏会有相对频繁的状态跳转,因此会有手动触发Lua GC的逻辑,可以让Mono内存不会累积到100多兆那么夸张的程度,因此对于玩家的影响不是很大。
这里把这个问题排查的大致过程分享出来,也提醒同样使用ToLua的其他项目可以提前关注下这部分的问题,当然更希望有更好解决方案的朋友来分享一下~
(就像前面提过的一样,这里的确有点怀念Python所使用的引用计数+标记清除的GC算法。我们只需要去解开循环引用,就可以让脚本的对象触发销毁逻辑,进而释放掉其对应的C#层的对象,这样就可以保证C#的GC可以释放掉应该释放的对象……)


2018年8月2日  于杭州海创园

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

×
懒得打字嘛,点击右侧快捷回复 【右侧内容,后台自定义】
您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

小黑屋|手机版|Unity开发者联盟 ( 粤ICP备20003399号 )

GMT+8, 2024-11-16 00:04 , Processed in 0.088420 second(s), 26 queries .

Powered by Discuz! X3.5 Licensed

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表