工作记录-记一次Unity Bug的定位
2019-02-05 21:00:000x00 前言
首先说明,这个标题稍微有些哗众取宠了,可能会产生分歧的点是在于这到底是不是 Unity 的 Bug。说到底其实也就是一个简单的工作记录。缘由是公司内工作室的一个团队遇到了他们在 Unity 应用层面很难定位的内存问题。我在经历了两周的工具分析以及 Unity 源码分析之后,确定了一个可重现的,让 Unity 内存不断增长的方式。这个方式也许是 Unity 的 Bug。但因为是春节前最后一天才确定的重现方法,时间关系没有仔细的从源码的角度去分析这到底是代码上有边界情况没有考虑到,还是这就是预期的效果。下面先说结论。
0x01 如何重新 Unity 粒子系统带来的内存增长问题
重现方式如下:
1. 使用了 `Unity` 的粒子系统,并且使用了 `Texture Sheet Animation` 模块,设置为 `sprite` 模式( `sprite` 不为 `None`)
2. 粒子使用的 `shader` 有多个 `pass`,并且至少有一个 `pass` 不被粒子系统使用(例如 `shadowcaster pass`)
3. 运行时通过脚本或者 `Animator` 等方式频繁设置粒子 `material` 的 `property` 值,并且这个 `property` 在上一条中的不被粒子系统使用的 `pass` 中被用到
测试了在 Unity 2018.2.12f1 中,每个 Update 设置100个 property,会带来每秒 2~3MB 的内存增长。而这部分内存增长在 Unity Profiler中体现在 Unity 这一类中,而且不能找到对应的 GameObject。
0x02 问题的定位过程的总结
这一节属于流水账,非常啰嗦,想看分析的可以直接略过。
从项目组得到的信息很少。首先项目是一个web+微端游戏,使用 Unity 制作并且发布到 WebGL+PC 平台。对于网页游戏,长时间的挂机是很常见的。在项目测试的时候发现游戏在进入主城之后,没有任何操作的情况下,一晚上会有3GB的内存增长。当时我听到这个信息是很崩溃的,首先没有固定的重现方法,也就是并不是进行某个特定操作会带来内存增长。其次是项目组是在一个很长的时间上来测量的内存增长,可能不是线性增长,也可能有其他应用的影响。所以拿到这个任务的第一天,正好也是下班时间了,我就打开了项目的微端版本,用vs2017附加进程,打开堆内存分析,然后就下班了。第二天上班第一件事就是查看vs,发现内存确实增长了将近3GB,马上截取一帧,希望能分析出一点东西。
这时候遇到了第一个问题,打开了堆内存分析,看到内存申请的地方都是“未知帧”,其实也就是没有加载符号。Google了一下 Unity 的官方文档,找到了这一篇,https://docs.unity3d.com/Manual/WindowsDebugging.html, Unity 提供了一个符号服务器,http://symbolserver.unity3d.com/,通过一番vs设置,重新加载了一下符号之后,函数名都出来了。
这时候遇到了第二个问题,并且在这个问题纠结了好几天。从内存分析结果来看,内存增长的函数都是 GfxDeviceClient::EndRecording 这个函数。我以为我找到了问题,就开始去看代码了。简单的说就是 Unity 对每个 pass 做了一个 property 的缓存,包括 vector,float,matrix,texture 等等这些值,如果缓存存在并且有效,则会在应用这个 pass 的时候直接 apply 这个缓存。而每一次调用 Material.SetFloat、Material.SetVector 等等这类设置 property 的接口的时候,会 invalidate 这个缓存,重新 Record。而 GfxDeviceClient 中有 BeginRecording 和 EndRecording 这两个函数, BeginRecording 很简单,设置一些 Flag 让 GfxDeviceClient 知道自己在 Recording 状态,然后 EndRecording 函数会检查是否 Record 成功,然后把 GfxDeviceClient 记录的缓存拷贝给 material 内部的缓存。内存增长的地方就在这个拷贝构造函数中。于是我开始分析到底是哪个 material 的哪个 pass 一直在设置 property。断点了一些地方,查看了一些临时变量,发现是 particle 相关的几个材质。这时候我做了一个比较SB的决定,因为想到时间紧迫,应该一切以解决问题为目标。我把目光放到了项目代码上,我觉得肯定是代码上哪里写出了问题。这时候我想到项目使用了 FairyGUI,并且做了一点小修改,我就在进入游戏主城时候,关掉 FairyGUI 的根结点,发现内存的确不增长了。我就开始查 FairyGUI 哪里出问题了。查呀查,查了好几天,发现项目在GUI上插入了一些粒子,并且设置了 GoWrapper.supportStencil=true。把设置 stencil 的地方注视掉之后,即使不关掉 FairyGUI 的根结点,内存也不涨了。于是给了项目组一个临时解决办法,就是先不要用 FairyGUI 的 Stencil 相关功能。有了临时解决办法,才安心回到了从根源找问题的道路上。
其实找临时解决办法的过程中也不是完全一无所获,我注意到 FairyGUI 的 GoWrapper 这个 MonoBehaviour 会在 Update 函数中去更新 surportStencil 的 GameObject 的 material 的 stencil 相关的 property。但是到底为什么频繁设置这些值就会出问题呢,我没有头绪,并且尝试新建了一个 Unity 工程去重现这个问题,也没有重现。然后我回到了vs和 Unity 源码上。首先我发现 EndRecord 这个函数每次申请的都是 4K 的内存,意识到 Unity 自己有管理堆内存,EndRecord 这个函数可能只是压死骆驼的最后一根稻草,真正申请内存而没有释放的是在另外的地方,考虑到 Unity 的主循环是非常规律的,所以才每次都在 EndRecord 这个函数。
这时候遇到了第三个问题,我要怎样去分析 Unity 怎么管理的堆内存呢?然后我就去看源代码的 MemoryManager 这个类了。因为我先入为主的认为可以通过log的方式去看看哪些地方申请了内存,哪些地方释放了内存,做一个差异对比,就知道哪里申请的内存泄漏了。然后自己开放了一个api,可以打开 MemoryManager 的log记录,编译了一个版本,准备拿出来放到项目中试一试。恰巧编译的这个时间里,我继续看了一下 MemoryManager 的代码。发现自己又SB了一次。原来 Unity 有一个 -systemallocator 的开关,可以绕过 Unity 自己的堆内存管理,更好的分析内存使用。https://answers.unity.com/questions/1452095/the-systemallocation-switch.html。于是乎我用vs重新做了一次内存分析。这次的结果就有用多了。内存的增长实际上来自于 ShaderPropertySheet::UnshareForWrite 函数,函数堆栈中有 UVModule::PrepareForRender。看了一下代码,有了一点头绪。首先这个函数内部有个宏开关 ENABLE_MULTITHREADED_CODE,而PC是默认开启多线程并且粒子更新跑在worker线程中。所以 ShaderPropertySheet::UnshareForWrite 这个函数会每次new一个新的 ShaderPropertySheet,而把原来的 ShaderPropertySheet 的引用计数减一。然后定位了内存增长的地方之后就去尝试重现问题了。企图把问题做成一个测试工程发给 Unity 官方,然后官方给个解释。
0x03 问题的简单分析
说到内存问题,一般我们会用内存泄漏这个词语来描述。但其实内存泄漏也分为广义定义和狭义定义。从广义上来说,也就是从项目的角度,我今天说的这个问题就是内存泄漏。但从引擎的角度, Unity 对堆内存进行了管理,在系统层面内存并没有泄漏,在关闭应用甚至切换场景的时候,这些内存的的确确会被释放。在上一节我已经尽量避免使用内存泄漏这个词。
因为春节也看不到 Unity 源码了,只能脑补分析一下这个问题出现的原因。
首先为什么必须要是粒子系统,很简单,因为粒子系统的更新跑在另一个线程。
其次为什么要使用 sprite mode 的 Texture Sheet Animation 模块,因为泄漏来自于粒子的 UVModule,并且if判断 renderData 不为 NULL,只有使用了 sprite mode 并且设置正确的 sprite 才会有 renderData
最后是为什么要有多 pass,而且更新的property要在没用到的 pass 里。接下来就暂时只是个人猜测了。我觉得可能是在 Particle 线程中创建的 ShaderPropertySheet 并没有在 Gfx 线程中使用到,所以引用计数没有减少,造成了泄漏。我测试了更新 forward_base 的 pass 中的 property,没有内存增长,测试了多个 pass ,但是 pass 都被使用,也没有内存增长。
最后的解决方法很简单,让项目把particle的 shader 里面没有用到的 pass 都删掉。
0x04 展望
拿到 Unity 代码不到两周时间,通过阅读代码解决项目的一个问题,我觉得比较欣慰了。但是在这个过程中还是走了很多弯路,包括最后也没有真正找到问题的根源,仅仅是猜测了一下。春节之后回到公司还有继续研究下代码,验证一下自己的猜测,或者找到真正的问题。如果我猜错了,我再更新吧。