游戏蛮牛学习群(纯技术交流,不闲聊):159852603
游戏蛮牛 手机端
开启辅助访问
 找回密码
 注册帐号

扫一扫,访问微社区

开发者专栏

关注:2353

当前位置:游戏蛮牛 技术专区 开发者专栏

__________________________________________________________________________________
开发者干货区版块规则:

  1、文章必须是图文形式。(至少2幅图)
      2、文章字数必须保持在1500字节以上。(编辑器右下角有字数检查)
      3、本版块只支持在游戏蛮牛原创首发,不支持转载。
      4、本版块回复不得无意义,如:顶、呵呵、不错......【真的会扣分的哦】
      5、......
__________________________________________________________________________________
查看: 1027|回复: 17

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

[复制链接]  [移动端链接]
排名
3
昨日变化

6973

主题

7498

帖子

2万

积分

Rank: 16

UID
1231
好友
185
蛮牛币
10447
威望
30
注册时间
2013-7-29
在线时间
3583 小时
最后登录
2018-11-16

社区QQ达人活力之星原创精华达人突出贡献奖财富之证游戏蛮牛QQ群会员蛮牛妹VIP

发表于 2018-11-2 10:57:10 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

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

x
1. 起因
游戏上线之前大约不到一周的时间,安卓和iOS包都提给渠道之后,合作方的质检部门给出了一个测试报告,说我们游戏有严重的内存泄露……
我裤子都……呃,不好意思,我包都提上去了,这个时间点你跟我说这个,早干嘛去了!而且内存部分也一直是我们关注的内容,不管是我们内部的周常测试还是定期的UWA的性能测试,在内存这块都没有发现特别明显的问题
2. 初步处理
先初步沟通了下,内存泄露的结论是在做频繁开关ui的测试时得出的,依据是PSS内存一直在增长,而且在中低配机器上都超过了建议的阈值。沟通到这里心里稍微放松了下,因为我们为了减少UI的顿卡,针对ui做了缓存机制——对大内存设备(android设备1.5G以上,iOS设备1G以上),较为复杂的界面会做一定时长的缓存,提高近期再打开的时候的体验。这个缓存最初并没有设置上限,因为设想玩家在正常流程中,并不太会连续打开非常多界面,而到一定时间界面没再打开过就会释放掉了。而合作方的这种测试正好和我们的缓存机制冲突,因此得出内存泄露的结论也可以理解。
首先尝试跟合作方解释了一下原因,然后着手做了一下缓存个数的限定,并顺手把安卓设备上的大内存定义从1.5G提高到了2G。通过Patch更新完成之后让质检部门测试,得出结论是——有略微好转,但是依然有泄露风险。并且很好心地做了一个测试:
手动测试,针对每个界面执行30次打开和关闭操作,并且每次之后手动打点,所有的17个主要依次打开之后PSS内存的增长曲线如下图所示。
1.jpg
PSS内存增长曲线图
这下就有点尴尬了......
3. 复现和定位
看合作方给出的内存曲线图,问题的确比较严重,PSS内存从400M增长到600M+,虽说中间有部分降低的过程,但是整体上升的趋势还是非常明显。

这样看来,和界面缓存机制并没有特别直接的关系,之前的判断并不正确。虽然上线之前事情很多,这个还是要花时间来处理。于是先尝试复现,方法很简单,作为程序不需要手动开关界面,编写一个debug功能,针对列表中的界面模拟开关操作就好了。

PSS内存不好查看,用两种方式分别验证:
  • 用adb连接手机,使用adb shell dumpsys meminfo <packagename>命令来手动查看内存变化;
  • 让QA同学帮忙跑了一下UWA GOT工具的OverView测试,查看PSS曲线。

结论都是一样的,PSS内存的确存在较快的增长,应该是UI导致游戏内存泄露了。

我们平常对于内存的关注中,虽然PSS内存一直也偏高,但是没有观察到过这么明显的泄露现象,也去翻了下近期的UWA测试报告,基本PSS内存的曲线是这样的:

2.jpg
UWA测试中的PSS内存曲线
整体偏高,但有升有降,后半部分还是趋于平缓的。但是为什么在这种连续开关ui的极限测试下会有这么明显的泄露现象呢?


复现之后,接下来的问题就是定位具体泄露的部分是什么。因为增长的是PSS内存,所以要分别看下各个部分的内存占用变化。

首先怀疑的是贴图等资源泄露了,因为这种上百兆的内存增长,感觉资源泄露的可能比较大,同样使用UWA的GOT工具来看Assets部分的变化,在针对一个ui频繁开关的时候,并没有什么变化,使用Unity Profiler的Memory部分的Detail视图在设备上来看也是没有任何的变化的。

切换回Profiler的Simple视图来看,发现Mono有很明显的增长!整个测试做下来的话,可以从最初的30多M增长到大约160M+,这跟PSS的内存增长规模是比较契合的。也看了下日常UWA测试中Mono内存的增长曲线,并没有特别明显的泄露:
3.jpg
UWA测试中的Mono内存曲线
那么,现在的结论就是——

看上去UI的频繁开关会导致Mono内存的泄露!
4. 工具排查
定位了泄露的大头是Mono内存之后,接下来就是要检查具体的泄露内容是什么。设计了一个简单的测试用例,针对单个ui开关多次,查看前后的mono内存差异。

因为观察之前的mono内存会有降低的情况,说明在这个过程中会有GC的触发,但是并不能释放掉,所以感觉是真正的泄露,而不是由于没有触发到GC导致的“伪泄露”。基于这个推断,在每次测试之后都手动调用一下完整的GC逻辑,来避免可以被回收内存的干扰。

首先使用UWA的Mono工具进行排查,通过Persistent模式看到的差异数据有些复杂:
4.jpg
UWA Got工具看到的Mono内存驻留情况
UWA目前的功能是每隔1000帧做一次Mono内存驻留的快照,这对于长时间的测试足够了,而且对于运行性能以及内存影响比较小(最新版本的UWA GOT工具已经支持手动Sample了~~)。但是针对我们这种针对性的测试不是特别理想,看了下wetest,有手动snapshot的过程,申请了一个账号试用了下。设计了一个单ui开关多次的测试用例,并且在开始和结束做了完整的GC。
5.jpg
6.jpg
通过差异,看似乎在界面的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#对象。
[AppleScript] 纯文本查看 复制代码
//……
if (metaMap.TryGetValue(t, out reference))
{
    LuaDLL.tolua_beginclass(L, name, baseMetaRef, reference);
    RegFunction("__gc", Collect);
}
else
{
    reference = LuaDLL.tolua_beginclass(L, name, baseMetaRef);
    RegFunction("__gc", Collect);                
    BindTypeRef(reference, t);
}

Collect函数的定义如下:
[AppleScript] 纯文本查看 复制代码
public static int Collect(IntPtr L)
{
    int udata = LuaDLL.tolua_rawnetobj(L, 1);

    if (udata != -1)
    {
        ObjectTranslator translator = GetTranslator(L);
        translator.RemoveObject(udata);
    }

    return 0;
}

//lua gc一个对象(lua 库不再引用,但不代表c#没使用)
public void RemoveObject(int udata)
{
    //只有lua gc才能移除
    object o = objects.Remove(udata);

    if (o != null)
    {
        if (!TypeChecker.IsValueType(o.GetType()))
        {
            RemoveObject(o, udata);
        }

        if (LogGC)
        {
            Debugger.Log("gc object {0}, id {1}", o, udata);
        }
    }
}

为了验证这部分泄露的情况,同事又在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的逻辑,需要手动进行释放,好处是可以精准控制,但是坏处是很繁琐,需要对于代码做大量的重构。
[AppleScript] 纯文本查看 复制代码
[MonoPInvokeCallbackAttribute(typeof(LuaCSFunction))]
static int Destroy(IntPtr L)
{
	return ToLua.Destroy(L);
}

public static int Destroy(IntPtr L)
{
    int udata = LuaDLL.tolua_rawnetobj(L, 1);
    ObjectTranslator translator = ObjectTranslator.Get(L);
    translator.Destroy(udata);
    return 0;
}

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可以释放掉应该释放的对象……)

知乎@Funny David

评分

参与人数 2鲜花 +7 收起 理由
kckbkckb2 + 5 很给力!
野麻 + 2 很给力!

查看全部评分


跟我念“站长妹纸萌萌哒!”我说站长,你说YO!爱你们么么哒~
回复

使用道具 举报

5熟悉之中
784/1000
排名
3817
昨日变化
14

0

主题

170

帖子

784

积分

Rank: 5Rank: 5

UID
2623
好友
0
蛮牛币
1363
威望
0
注册时间
2013-8-26
在线时间
242 小时
最后登录
2018-11-16
发表于 2018-11-2 12:21:01 | 显示全部楼层
[发帖际遇]: zuoyamin 发帖时在路边捡到 2 蛮牛币,偷偷放进了口袋. 幸运榜 / 衰神榜

回复

使用道具 举报

8常驻蛮牛
5230/10000
排名
189
昨日变化
12

74

主题

831

帖子

5230

积分

Rank: 8Rank: 8

UID
120040
好友
1
蛮牛币
3629
威望
0
注册时间
2015-8-28
在线时间
2173 小时
最后登录
2018-11-16
发表于 2018-11-2 13:41:22 | 显示全部楼层
太牛了……

回复

使用道具 举报

5熟悉之中
626/1000
排名
4657
昨日变化
26

0

主题

182

帖子

626

积分

Rank: 5Rank: 5

UID
267103
好友
0
蛮牛币
1353
威望
0
注册时间
2018-1-31
在线时间
144 小时
最后登录
2018-11-16
发表于 2018-11-2 13:43:37 | 显示全部楼层

回复

使用道具 举报

7日久生情
2038/5000
排名
1199
昨日变化
2

11

主题

280

帖子

2038

积分

Rank: 7Rank: 7Rank: 7Rank: 7

UID
76278
好友
0
蛮牛币
5340
威望
0
注册时间
2015-3-1
在线时间
807 小时
最后登录
2018-11-15
发表于 2018-11-2 14:42:08 | 显示全部楼层
lua里面尽量用self语法,require的类,不用的时候设置package.loaded为空,这样可以避免很多lua对已经销毁的gameobject的引用。
比如 do这两段代码:
第一种:
test={}
function test.Init(obj)
   test.obj=obj
end
第二种:
test={__index=test}
function test.New()
   local o={}
   setmetatable(o,test)
  return o
end

function test:Init(obj)
   self.obj=obj
end
第一种情况,只要你不手动设置test.obj为nil,或者重新dostirng,那么,这个test.obj就会一直保存着gameobject的引用,因为test是一个全局的table
第二种你只要把对New出来的o的引用全清掉,在调用lua的GC时候,就会释放掉o的所有引用,因为gameobject是保存在o这个table,全局的test只是作为o的元表而存在,并没有引用任何变量

点评

这才是真大佬!  发表于 2018-11-2 18:15

回复 支持 2 反对 0

使用道具 举报

4四处流浪
338/500
排名
7784
昨日变化

0

主题

98

帖子

338

积分

Rank: 4

UID
218300
好友
0
蛮牛币
315
威望
0
注册时间
2017-4-18
在线时间
90 小时
最后登录
2018-11-14
发表于 2018-11-2 15:25:38 | 显示全部楼层
不错不错 学习了
[发帖际遇]: 追逐浪尖 乐于助人,奖励 2 蛮牛币. 幸运榜 / 衰神榜

回复

使用道具 举报

3偶尔光临
267/300
排名
33770
昨日变化
16

0

主题

198

帖子

267

积分

Rank: 3Rank: 3Rank: 3

UID
247666
好友
0
蛮牛币
38
威望
0
注册时间
2017-10-9
在线时间
61 小时
最后登录
2018-11-12
发表于 2018-11-2 17:04:04 | 显示全部楼层

不错不错 学习了

回复

使用道具 举报

5熟悉之中
787/1000
排名
4939
昨日变化
1

3

主题

343

帖子

787

积分

Rank: 5Rank: 5

UID
269155
好友
2
蛮牛币
1328
威望
0
注册时间
2018-2-22
在线时间
165 小时
最后登录
2018-11-15
发表于 2018-11-3 09:10:10 | 显示全部楼层
这是怎么学会的
[发帖际遇]: 张瑞国 发帖时在路边捡到 1 蛮牛币,偷偷放进了口袋. 幸运榜 / 衰神榜

回复

使用道具 举报

7日久生情
2523/5000
排名
285
昨日变化
1

0

主题

125

帖子

2523

积分

Rank: 7Rank: 7Rank: 7Rank: 7

UID
18512
好友
0
蛮牛币
4610
威望
0
注册时间
2014-3-22
在线时间
564 小时
最后登录
2018-11-16
发表于 2018-11-3 09:41:04 | 显示全部楼层
学习中。。。。。。

回复

使用道具 举报

排名
23350
昨日变化
12

0

主题

36

帖子

69

积分

Rank: 2Rank: 2

UID
86274
好友
0
蛮牛币
90
威望
0
注册时间
2015-3-28
在线时间
15 小时
最后登录
2018-11-14
发表于 2018-11-3 13:46:42 | 显示全部楼层
难道没有什么方便排查内存泄漏的工具吗

回复 支持 反对

使用道具 举报

排名
62663
昨日变化
28

0

主题

2

帖子

4

积分

Rank: 1

UID
302288
好友
0
蛮牛币
2
威望
0
注册时间
2018-11-3
在线时间
2 小时
最后登录
2018-11-6
发表于 2018-11-3 13:57:42 | 显示全部楼层
谢谢分享

回复

使用道具 举报

3偶尔光临
150/300
排名
16365
昨日变化
8

0

主题

39

帖子

150

积分

Rank: 3Rank: 3Rank: 3

UID
13904
好友
2
蛮牛币
32
威望
0
注册时间
2014-2-9
在线时间
71 小时
最后登录
2018-11-14
发表于 2018-11-4 16:46:24 | 显示全部楼层
谢谢222222
[发帖际遇]: 一个袋子砸在了 zcyandy 头上,zcyandy 赚了 1 蛮牛币. 幸运榜 / 衰神榜

回复

使用道具 举报

6蛮牛粉丝
1250/1500
排名
1572
昨日变化
10

0

主题

169

帖子

1250

积分

Rank: 6Rank: 6Rank: 6

UID
137070
好友
0
蛮牛币
2458
威望
0
注册时间
2016-2-20
在线时间
303 小时
最后登录
2018-11-16
发表于 2018-11-5 09:51:56 | 显示全部楼层
感谢分享!虽然也用ToLua,但真没做过这么频繁的打开/关闭测试,更没研究过相应的回收机制,
我自己回去测了下,真是这样!
大佬的文章真心让我大开眼界,又发现了一个大坑!
感谢楼主分享!

回复 支持 反对

使用道具 举报

5熟悉之中
562/1000
排名
4486
昨日变化
20

0

主题

117

帖子

562

积分

Rank: 5Rank: 5

UID
264326
好友
0
蛮牛币
761
威望
0
注册时间
2018-1-14
在线时间
131 小时
最后登录
2018-11-16
发表于 2018-11-5 09:53:34 | 显示全部楼层

回复

使用道具 举报

6蛮牛粉丝
1022/1500
排名
3133
昨日变化

1

主题

166

帖子

1022

积分

Rank: 6Rank: 6Rank: 6

UID
139214
好友
0
蛮牛币
1534
威望
0
注册时间
2016-3-12
在线时间
405 小时
最后登录
2018-11-16
发表于 2018-11-5 13:36:17 | 显示全部楼层
学习了。。。

回复

使用道具 举报

您需要登录后才可以回帖 登录 | 注册帐号

本版积分规则

关闭

站长推荐 上一条 /1 下一条

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