一点心得


  • 首页

  • 归档

构建Unity性能监控管线

发表于 2025-02-05

监控流程思考

随着项目的规模变大,出现性能问题的点可能会变得更加多,更加广。这种情况做性能优化时很容易让自己陷入无穷无尽的定位问题中,非常的繁琐和浪费时间。总结过往经验,很多定位问题的工作都是重复的,有个标准化,自动化的性能监控管线可以事半功倍。

性能主要指标:帧率、卡顿率、功耗、内存。

理想监控流程:

1
数据采集  → 自动分析 → 问题定位 → 修复验证

关于数据采集方式:

  • Release 包不能采集详细的业务中的性能数据(比如Unity Profiler数据),但是数据准确,没有测试环境干扰。
  • Development 包能够采集详细的业务中的性能数据,但是数据不准确,有测试环境的干扰(比如自己添加了Profiler性能桩之后,因为Profiler本身的性能开销,导致整体性能会比Release包要低,上了规模的项目这种测试功能非常多,干扰的比较大)。

这里有个矛盾点:

  • 数据准确的Release包不能采集详细的业务性能数据,这就导致不能做很详细的问题定位。
  • 数据不准确的Development的数据能够采集到详细的业务性能数据,但是测试出来的性能又不能作为是否达标的标准。

基于此,我们可以把流程拆分开来,分别采集Release包和Development包的数据,Release包的数据用来判断性能是否达标,以及粗略分析问题,当Release包的数据不足以定位出问题的时候,再采集Development包的数据用来详细定位问题。

改进后监控流程:

1
2
3
4
5
数据采集(Release数据) → 自动分析 → 问题定位 →
if 问题明确 then
→ 修复验证
else
→ 数据采集(Development) → 自动分析 → 问题定位→ 修复验证

为了区分Release包的采集数据和Development包的采集数据,后面我们规定,Relase包的采集数据为 一级数据,Development包的采集数据为二级数据。

性能监控工具

对于Release包而言,我们能采集的数据比较有限,不过在有限的数据中我们尽可能的提供有价值的数据,帮助我们再Release包的性能数据中直接可以定位问题。此外数据采集本身这个行为是完全可以做成自动化的,这里的自动化包括以下流程:

1
2
3
4
5
6
7
发起自动化采集流水线(手动) 
  ↳ 流水线构建包(自动) 
  ↳ 测试设备安装App(自动) 
  ↳ 测试设备启动游戏登录并且进去采集数据的单局 (自动) 
  ↳ 自动战斗并且采集指定数据(自动) 
  ↳ 单局结束上传采集数据(自动)
  ↳ 通知采集完成到企微(自动)

其中涉及到登录和点击UI这种自动操作可以用 GAutomator 来处理。 单局中的角色自动战斗的处理有很多方法,比如专门制作AI行为树或者使用类似对局的回放功能,只要能保证每次测试行为和环境一致即可。

PerfDog (一级数据)

首先推荐一个第三方的性能工具就是 PerfDog,可以作为标准化数据(什么是标准化数据?标准化数据我这里指代作为性能的依据和参考),PerfDog能够采集出非常丰富的性能指标数据:帧率、卡顿情况、内存、功耗、CPU&GPU 使用信息等等。

另外PerfDog提供了开放的API,可以采集数据的同时上传自定义业务性能指标数据(当前帧创建怪物数量、创建的特效数量等等),这个对分析问题非常有帮助。当PerfDog采集出来的数据,某一帧存在严重卡顿,如果我们有采集自定义业务性能指标,那我们就可以直接根据卡顿点,关联到业务的性能指标了。举个例子:PerfDog显示采集的数据中第n帧卡顿,我们直接可以看到这一帧业务中的Actor的创建数量和总数量:

在自动化采集框架中,可以使用PerfDog提供的PerfDogService来采集,PerfDogService采集的数据在本地,可以做一些自定义行为分析,输出问题。比如,可以提取采集数据中的平均帧率内存等信息存档,并且和数据库中的基线值或者之前版本的数据自动对比输出一份最新的对比数据,来判断当前的版本的数据的问题点,大概是这样的:

‘AppName_release_b_10001’ 版本 release ‘LevelName单局’ ‘PhoneName设备’ 的 性能报告数据:

指标 当前值 基线值 变化
AvgFPS 57 60 -5% ⚠️
BigJank(/10min) 6.8 6 +13% ⚠️
Peak(Memory)[MB] 1.2GB 1GB +20% 🔴

有了这个对比数据之后,我们知道此单局在对应设备上相比于基线值哪些数据不合格,不合格之后我们需要进一步分析原因。

模块耗时 (一级数据)

模块耗时这个也是为了在一级数据中采集更多信息来帮助分析问题的,主要是帮助分析CPU侧耗时开销。一级数据中我们主要对关键模块的耗时进行统计(统计的过多会影响性能)。

这里解释下我指代的关键模块,比如引擎相关的 Rendering.RenderFrame、Rendering.Submit、EventSystem.RaycastAll等,业务层的 子弹模块根部的Tick、技能Timeline模块根部Tick、加载模块的根据Tick等,耗时我们可以通过 Time.realtimeSinceStartup 的时间,然后记录函数调用开始和结束的时间差来统计,引擎里面的模块函数统计可以通过 PlayerLoopSystem 处理,可以在PlayerLoopSystem的subSystemList中插入自己的统计updateDelegate,具体的做法可以参考这篇文档。有了这些数据,我们可以通过和基准数据对比,看哪部分上涨了,就可以分析出来CPU耗时上涨的大概原因了。形式大概如下:

‘AppName_release_b_10001’ 版本 release ‘LevelName单局’ ‘PhoneName设备’ 的模块耗时数据:

1
2
3
4
5
6
7
8
9
10
11
函数名                         当前值          基准值          变化
------------------------------------------------------------------
-逻辑                           10ms           12ms           +20% ⚠️
--BulletModule.Tick             2ms            2ms            0% 
--SkillModule.Tick              3ms            3ms            0%
--LoadModule.Tick               1ms            1ms            0%
--UIModule.Tick                 2ms            3ms           +50% ⚠️
--EventSystem.RaycastAll        2ms            2ms  
-渲染                           8ms             8ms            0%
--Rendering.RenderFrame         3ms            3ms            0%
--Rendering.Submit              2ms            2ms            0%

数据采集之后可以自动分析数据生成如上数据,这样可以看出 UIModule.Tick 消耗明显提高了,到这里我们可以从两个方向可以继续查这个问题,第一个最直接的直接找业务同学问这里是否有UI内容的增加,有内容增加的话,可以直接去分析具体增加的内容。第二可以直接采集二级数据来详细分析耗时新增具体是哪里引起来反推具体的内容。

内存概要(一级数据)

Unity项目中的内存主要包括以下几部分:

  • Managed memory (托管内存):这部分内存是我们业务层C#代码使用的内存,IL2CPP之后虽然是C++代码,但是这部分内存任然是被托管的,被托管的内存自动分配和自动释放的。
  • Native memory:引擎C++使用的内存,包括加载assets的内存。
  • C# unmanaged memory:这部分内存是C#代码的,但是又是非托管的。比如使用UnsafeUtility.Malloc,UnsafeUtility.Free分配和释放的内存。这个目前我经历的项目都用到的比较少,这里不作为重点考虑。
  • Lua memory:现在很多项目都是使用Lua来实现UI系统模块的,甚至是战斗模块的。所以Lua内存占比还挺高的,在我们项目Lua内存峰值曾经达到120MB,需要重点关注。

前面提到的PerfDog能够统计到每帧的总内存使用情况,此外我们可以统计出来各类型的内存数据。Unity引擎提供了获取Mono内存和总内存的API:

1
2
3
4
5
6
7
8
9
// 获取总分配内存,包含Managed memory、Native memory 和C# unmanaged memory
long totalAllocated = Profiler.GetTotalAllocatedMemoryLong();
// 总Reserved内存
long reservedMemory = Profiler.GetTotalReservedMemoryLong();

// 总的托管内存 Used + Reserved
long monoHeap = Profiler.GetMonoHeapSizeLong();
// 使用的托管内存
long usedMono = Profiler.GetMonoUsedSizeLong();

此外Lua内存的获取语言本身提供了API:

1
2
3
4
int lua_gc (lua_State *L, int what, int data);

//This function performs several tasks, according to the value of the parameter what:
//LUA_GCCOUNT: returns the current amount of memory (in Kbytes) in use by Lua.

有了这些API同样,我们可以每帧统计这些数据来做自动分析对比。类似模块耗时一样,我们做一个内存分项和基准值或者历史版本值对比来发现问题。形式大概如下:

‘AppName_release_b_10001’ 版本 release ‘LevelName单局’ ‘PhoneName设备’ 的内存数据:

1
2
3
4
5
6
7
8
分项                             当前             基准值            变化
----------------------------------------------------------------------
-Total                          1.2GB             1GB             +20%
-Reserved                       300MB             300MB             0%
--Managed memory                400MB             360MB           +11%
---Reserved Managed memory      100MB             100MB             0%
---Used Managed memory          300MB             260MB           +15% 
--Lua                           100MB             100MB             0%

这里对比是对比的一场单局的内存均值,可以自己拓展细分项内存的指标,如果有引擎代码的话可以把 Texture2D内存这种比较重量级的都统计出来,可以方便通过一级数据就可以确定大概问题了。

UnityProfiler数据(二级数据)

针对帧率,卡顿率问题

正如监控流程中描述的一样,如果一级数据不能够定位出问题,那就需要继续采集二级数据来详细分析。针对帧率和卡顿率问题,我们可以采集UnityProfiler数据来做分析,Profiler本身只有少量的函数采集,我们可以把自己的耗时模块函数都添加上性能桩:

1
2
3
Profiler.BeginSample("CustomFunctionName);
// 需要采集的函数
Profiler.EndSample();

这里有一点需要注意,性能桩越多,Profiler本身对性能的影响就越大,也就是数据越失。不过我们已经通过一级数据定位到大概位置,这里Profiler采集的二级数据只作为查问题的数据,建议桩能够覆盖全项目的耗时函数,性能桩的API只会在Development包里面生效,Release包不会编译进库。

因为是自动化采集的,采集Profiler数据通过Unity的脚本化接口来采集而不是真机连接Editor的Profiler,这个Unity本身提供了API:

1
2
3
4
5
// Specify the profiler output file
Profiler.logFile = Path.Join(Application.persistentDataPath, "appname_datetime_perf.raw");
Profiler.enableBinaryLog = true;
// Start profiler
Profiler.enabled = true;

采集数据之后我们可以用Profiler + Profiler Analyzer来分析,主要还是Profiler Analyzer工具,此工具可以统计单个性能桩在整个数据中的平均消耗等数据,会把TopN的峰值消耗筛选出来,并且还可以用来比较两个Profiler数据,非常的方便:

有自定义性能桩的话可以一步一步追溯到具体的消耗的函数。另外我们可以对这个数据也做自动化分析处理,我们可以自己尝试解析Profiler数据来提取卡顿帧,以及版本对比提取耗时超出的性能桩数据,具体可以参考ProfilerAnalyzer的处理方法。

Dump&Trace内存数据(二级数据)

内存

Dump内存我用的是HeapExplorer,因为MemoryProfiler对Unity2019版本有些不兼容。HeapExplorer之前我具体介绍过,具体可以看这篇[Unity内存(二)内存快照]。简单来说就是查看当前帧正在利用的这些内存由哪些对象,可以定位出那些长时间不释放,或者由于BUG没有释放的内存。

内存Trace我是自己做的工具,详细的做法和功能可以看这篇[Unity内存(三)内存追溯]。Trace解决的是Reserved内存过大的问题,方式是记录每次分配来源,因为有些对象确实释放了,但是因为过程中频繁分配,并且大量分配,我们需要追溯到这些分配才能定位到具体的分配代码位置。

资源加载数据(二级数据)

内存

资源加载是一个纯逻辑层可以做的数据统计,但是这个又很关键,因为资源加载往上涉及到具体的业务,往下涉及到系统的IO和引擎内存。如果能够监控住资源加载,自动检测到变化异常,对提早发现那些场景资源量过大,同时实例化对象过多,甚至是功耗过大这种问题非常有帮助。

资源加载可以从GameObject.Instantiate、AB的LoadAsset、AB加载等来监控。指标上可以是每帧加载的数量、加载的耗时、当前已经加载的总数量来统计。因为和具体项目相关性太高了,这里不做详细的讨论,每个项目资源处理的方式可能不尽相同。

完善监控流程

到此,我认为的性能监控的主要涉及到的性能指标都讲了对应的性能监控工具以及简单介绍了分析方法,当然根据自己的项目可以再做更加详细的监控指标,比如渲染相关的场景中对象DrawCall次数,ParticleSystem运行的数量,逻辑相关的还可以用SimplePerf等等。下面完善一个相对正式的性能监控流程:

此外,针对上面提到的模块耗时问题,这里多提一点。在我经历的项目中,经历过两种性能优化的工作流:

a. 性能的同学定位出具体的问题,找到具体的原因以及修复方式,然后再找出问题的代码的作者或者功能负责同学处理。

  • 优势:
    • 这种模式模式的好处就是,业务的同学不需要关注性能,出了问题会有专门的人帮自己分析问题,自己只需要修改问题就行,减少了业务同学的压力。
  • 弊端:
    • 就是性能同学需要对业务足够熟悉,因为有些性能问题不是单个函数造成的,而是一个具体的功能逻辑并发造成的普遍消耗上涨,性能同学的压力就比较大。
    • 由于业务同学不会接触到性能的具体事物中,平时做业务时对性能敏感度比较低,输出的业务性能出现问题的可能性相对较大。
    • 由于业务同学不需要关注性能,这里有个冲突点就是,当性能同学查出具体问题的时候,往往是在版本中期之后甚至是版本后期,此时派单到业务同学这里处理,业务同学这里会存在业务需求和性能需求冲突的问题,这里就需要有专业的PM来管理这里的需求关系,不然比较容易造成业务同学这里需求优先级不明确,甚至没有预留处理性能需求时间(这个也比较难预估时间),造成性能问题难以推进的问题。

b. 性能同学只负责完善数据采集,完善问题分析工作管线以及引擎侧性能。具体模块的性能给到具体业务模块的负责人,那么模块负责人就是自己模块性能的owner,性能好坏就是你的KPI。当版本模块耗时统计数据出来之后,每个模块负责人关注自己模块是否存在性能问题,需要做二级数据分析的,自己分析,PM或者技术负责人整合推动性能进度,定期同步当前性能情况和问题处理情况。

  • 优势:
    • 业务同学自己关注自己模块的性能,有助于业务同学提升性能敏感度,对项目整体的性能意识提高非常有帮助。
    • 解放了性能同学分析具体问题的时间,可以更加专注优化整个性能管线,建立更加完善便利的自动化管线和分析工具。
    • 避免性能同学需要熟悉具体的业务逻辑来帮助分析数据的问题,以及在熟悉具体的业务时和大量业务同学沟通的时间损耗,也减少了性能需要需求推进的难度。
  • 弊端:
    • 业务同学自己分析性能问题从经验和熟练度上需要时间积累,这里会有些损耗。

这里列出这两种优化的工作流的在规模较大的项目处理性能问题时,用模块耗时这种明确问题职责式的性能处理方法有优势,也想表达从这两种工作流中我理解到光有技术是不够的,怎么用好这个技术来解决问题才是关键。


引用:

Optimize your game performance for mobile, XR, and the web in Unity

Unity 中的 PlayerLoop

Memory in Unity introduction

Lua 5.3 Reference Manual

阅读全文 »

Unity 应用Crash问题解决方法

发表于 2024-12-28

最近在处理Crash问题总结了一些心得,这里记录下。

Crash问题处理流程

Unity App Crash问题主要分为几类:

  • 系统资源(包括内存,CPU等)
  • 业务逻辑(主要是死循环这类,因为是C#写的没有空指针这种问题,Unity为数不多的有点)
  • 引擎自己的代码逻辑(空指针,死循环等)
  • 第三方SDK造成(这类最难查)
  • 其他原因

针对不同平台,我们定位Crash需要的数据信息也不一样:

  • Android: adb logcat日志 + so符号文件
  • iOS:ips日志 + dSYM符号文件
  • Window:dump日志 + pdb符号文件

可以看到无论哪个平台都需要符号文件,所以如果是标准化流程的话,构建包的流水线每次都应该生成符号文件,并且上传到云服务器,保证每个包都能取到对应的符号文件。另外这些平台都需要提供崩溃日志,但是如果是外网玩家出现的取不到日志的情况呢?一个实用的Crash工具CrashSight,App继承了CrashSight之后能够捕获到发生的Crash日志并且上报的服务器,我们只需要把包的符号文件上传到CrashSight服务器,CrashSight能够把每个上报的Crash堆栈自动找到对应的符号文件还原成符号化的堆栈。整个过程是这样的:

没有用CrashSight的可以按照这个流程来构建一个自己项目的解决Crash问题工作流。另外客户端发生的Crash问题 CrashSight 并不是每次都能捕获到的,其他工具也是如此。这种情况我们只能通过内存的适配和功能测试,有案例发生时,我们拿到日志文件首先符号化来还原堆栈,并且定位问题。CrashSight用户端的页面提供了一个符号化的工具,没有接入CrashSight的话我们也可以用自己做的工具。

说完了整体处理流程,再看下一些具体的Crash处理经验。

常见Crash问题具体案例

OOM (Out Of Memory)

oom和字面意思一样,就是系统内存不够或者给定当前的app的内存超出上限了,然后分配不到内存Crash了。

iOS系统有Jetsam机制,当内存不够时Jetsam会终止App,并且会生成Jetsam的ips日志,日志名字一般为 JetsamEventxxx.ips。所以当Crash时我们发现系统生成了Jetsam ips日志时,基本上可以判断是由于oom造成的。Jetsam日志里面会记录下当前App所有进程分配的内存情况,日志文件里我们可以看到如下内容(省略了很多内容的):

1
2
3
4
5
6
7
8
9
10
11
12
{"bug_type":"298","timestamp":"2020-03-19 17:30:28.39 +0800","os_version":"iPhone OS 13.3.1 (17D50)","incident_id":"7F111601-BC7A-4BD7-F468-CE3370053097"}
{
  "product" : "iPhone12,3",
  "pageSize" : 16384, //每个page占用的字节数
},
  "largestProcess" : "XXX(App的BundleName)",
  "genCounter" : 0,
  "processes" : [
  {
    "uuid" : "2dd5eb1e-fd31-36c2-99d9-bcbff44efbb9",
    "rpages" : 164, //占用的内存数 rpages * pageSize
  },

算出来内存大小可以帮助反馈App的内存性能状况,来做内存优化的参考。

Android系统在App内存过高时终止进程也会输出日志信息(Android系统各个厂商都有自己的定制,不一样每台手机每次都会生成,遇到过低端设备疑似没有生成的情况),Android系统的日志是直接输出的系统的调试Log中,我们可以通过 adb工具拿到。存储系统的日志本身是个循环利用的一块内存,我们需要在Crash发生之后就拿日志,否则系统如果继续运行最后存储Crash日志的那块内存会被重复利用,日志信息会被覆盖。

所以当测试同学遇到Crash问题,第一个动作就是通过adb工具获取log。几个常用的adb命令行:

1
2
3
4
5
//清理缓存。如果是专门的Crash测试,可以先执行清楚日志缓存信息再测试
adb logcat -c

//获取日志,并且输出到xxx_crash.log文件中
adb logcat -v time > xxx_carsh.log

拿到日志之后,可以直接在日志里面搜索关键字 lowmemorykiller 或者 kill,找到自己App的包名:

low_memory_killer

如果能找到自己包名的 lowmemorykiller (不同设备可能这个关键字不一样)可以表明就是oom问题了。

ANR(Android Not Responding)

ANR问题是安卓的特有问题,Android系统中,ActivityManagerServer(简称AMS) 和 WindowManagerServie(简称WMS)会检测App的响应时间,如果App在特定时间无法响应屏幕触摸或者键盘输入,或者特定事件没有处理完毕,就会触发ANR。出现以下任何情况时,系统都会针对您的应用触发 ANR:

  • Input dispatching timed out:如果您的应用在 5 秒内未响应输入事件(例如按键或屏幕触摸)。
  • Executing service::如果应用声明的服务无法在几秒内完成 Service.onCreate() 和 Service.onStartCommand()/Service.onBind() 执行。
  • Service.startForeground() not called:如果您的应用使用 Context.startForegroundService() 在前台启动新服务,但该服务在 5 秒内未调用 startForeground()。
  • Broadcast of intent:如果 BroadcastReceiver 在设定的一段时间内没有执行完毕。如果应用有任何前台 activity,此超时期限为 5 秒。
  • JobScheduler interactions:如果 JobService 未在几秒钟内从 JobService.onStartJob() 或 JobService.onStopJob() 返回,或者如果user-initiaed job启动,而您的应用在调用 JobService.onStartJob() 后的几秒内未调用 JobService.setNotification()。对于以 Android 13 及更低版本为目标平台的应用,ANR 会保持静默状态,且不会报告给应用。对于以 Android 14 及更高版本为目标平台的应用,ANR 会保持活动状态,并会报告给应用。

发生ANR拿到logcat日志之后,可以在日志里面看到:

anr

这里是5秒钟未响应事件,对于Unity的App来说其实没有给到明确的原因,发生这种情况一般是游戏的线程卡住了,导致未响应。此时我们需要根据Unity日志里面匹配时间点来看我们在处理什么逻辑导致卡住的(或者是App里面接入的SDK卡住的)。

后台限制

游戏运行过程中,有时候会被动切换到后台的情况,遇到几种常见的:

  • 登陆的时候切换到QQ确认,此时游戏会被被切换到后台。
  • 活动界面使用的H5做的(UniWebView) ,切换到H5界面时游戏会被切换到后台。
  • 系统权限提示框弹出来时,游戏也会被切换到后台。

Android系统对后台运行的App限制比较严格,当然不同的定制Android系统处理规则又不一样。比如这段时我最近遇到的,这是一台6GBRAM的手机出现Crash的日志:

system_service

可以看到尽管这台手机有6GB的RAM,但是切换到后台10秒钟之后就被系统终止掉了,这种情况我们只能通过系统保活方案来处理。

业务逻辑

业务逻辑造成的,有日志的话看Crash堆栈可以定位到具体代码,这个方式很明确,不需要多讲。


引用:

  1. iOS 内存 Jetsam 机制探究
  2. 带你打造一套 APM 监控系统 之 OOM 问题
  3. Logcat命令行工具
  4. Android ANR:原理分析及解决办法
阅读全文 »

Unity内存(三) 内存追溯

发表于 2024-12-01

上一篇中我们讲到了内存的Dump工具HeapExplorer,HeapExplorer可以帮助我们分析指定帧的内存情况,但是这还不够,我们还需要什么?

  • 能够记录到任何一次Mono内存的分配,并且能够反定位到分配来源,类似于一个深层次并且带上行号的堆栈。可以方便的定位问题来源。
  • 能够获取到每次分配的对象类型,这个便于统计我们内存分配情况,可以方便的确定优化点。
  • 能够很轻松的对比多局数据,查看分配的差异。可以方便的对比不同版本相同单局的分配差异,定位内存增长部分的问题。

基于这些需求,现有的工具显然无法满足,那么我们就需要自己动手,打造一个更加 Powerful 的工具。下面介绍下针对以上需求开发的 内存追溯工具MemTracer。

MemTracer

方案思路

本文最开始的部分,我们介绍了Mono内存的分配流程,其实就是为这里做铺垫的。在Mono分配流程中分析到,Unity的Mono内存分配最终统一收敛到三个分配宏:ALLOC_PTRFREE、ALLOC_OBJECT 和 ALLOC_TYPED。

1
2
3
4
//Object.cpp
#define ALLOC_PTRFREE(obj, vt, size) ...
#define ALLOC_OBJECT(obj, vt, size) ...
#define ALLOC_TYPED(dest, size, type) ...

如果我们在每次调用分配宏的时候记录下分配信息,就可以有完整的 Mono内存的分配列表了,流程是这样的(绿色部分是可以新增监听的流程):

我们新增 OnAllocEvent 函数插入在 ALLOC 宏定义后面,每次 ALLOC分配完成自动调用 OnAllocEvent,我们就可以记录分配的信息了:

1
2
3
4
//Object.cpp
#define ALLOC_PTRFREE(obj, vt, size)  { ... OnAllocEvent(obj,  size);}
#define ALLOC_OBJECT(obj, vt, size)   { ... OnAllocEvent(obj,  size);}
#define ALLOC_TYPED(dest, size, type) { ... OnAllocEvent(dest, size);}

另外这里的记录我们先缓存下来,然后在业务层每帧触发把缓存输出到本地文件中,把一帧的数据输出作为一次输出。

分配采样

找到了记录内存分配的时机,那么我们接下来要考虑的是具体记录什么数据。首先根据我们的需求目标,我们需要的是:类型信息、分配大小和分配堆栈,我们一个一个来看。

类型信息

类型信息其实很好获取,ALLOC的分配的对象类型都是Il2CppObject,前面已经分析了Il2CppObject结构,对象拥有类型为 Il2CppClass* 的成员变量 klass对象,klass 包含了类型名字:

1
2
3
4
5
typedef struct Il2CppClass
{
    const char* name;
    //...
}
分配大小

ALLOC参数带了申请分配的大小 size,但是通过前面的 bdwgc 部分了解,申请的大小和实际的分配大小其实不一样,实际的分配大小会内存强制对齐到 4 的整数倍。这个 bdwgc 提供了一个根据对象地址获取对象内存的方法:

1
2
//gc.h
GC_API size_t GC_CALL GC_size(const void * /* obj_addr */) GC_ATTR_NONNULL(1);
分配堆栈

获取堆栈可以直接用 c++里面 execinfo 的获取堆栈方法 backtrace , 保存堆栈的做法在 bdwgc 自己的调试中也有类似的功能,并且也是用的 backtrace 方法:

1
2
//os_dep.c
GC_INNER void GC_save_callers(struct callinfo info[NFRAMES])

三个数据都已经准备好了,接下来看下怎么保存这些数据。

首先提出保存这些数据的要求:

  • 数据尽量足够小,因为我们要保证最终的输出文件尽量小,然后数据尽量小可以保证采集内存时本身的性能开销相对较小。
  • 以一帧的数据为单位,这样我们在工具显示的时候可以按照帧的趋势来显示数据,便于阅读。

基于以上两点这里确定了下使用json紧凑的格式来存储采集的内存分配数据,下面是定义的记录内存分配数据的结构,一帧数据就用一个 MonoMemorySnapshot 来保存:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//单个类型的对象,在一帧中分配的信息
[Serializable]
public class MonoMemoryRaw
{
    //类型
    public string t;
    //t申请内存的总大小
    public int s;
    //t真实分配的内存总大小
    public int r;
    //t类型所有分配的堆栈
    public List<string> st;
    //每个堆栈对应分配的内存大小
    public List<int> sz;
}
//一帧内所有对象分配的信息
[Serializable]
public partial class MonoMemorySnapshot
{
    //帧号
    public long f;
    //分配数据
    public List<MonoMemoryRaw> d;
}

定义好了数据结构,然后直接实现采样功能即可。

地址解析

采样之后的数据是这样的:

上面截取了两帧的数据,对于这个数据我们还需要做最后一步处理,就是把记录的地址解析成真实的堆栈。因为 stacktrace 记录的是堆栈地址(stack address),如果想要转换成代码的堆栈的话,需要转换成符号地址:

1
symble address = stack address - slide address

这里的 slide address 是在程序加载的时候的基准地址,对于Unity工程我们直接获取UnityFramework的slide地址即可,获取出来可以保存再内存分配数据文件的头信息中,方便后面工具解析。然后解析直接用 macOS提供的 atos 指令即可。

1
atos -o 'dSYMs path' -arch arm64 -l 0x000000 {symble address}

这里有个点需要注意,如果项目规模比较大的情况并且内存分配的非常频繁,相应的内存分配记录文件也会比较大,记录的地址会非常多,会有几十万的地址需要解析。我们不要一个一个地址取解析,这将解析6小时+。我采取的策略是这样的:

  • 因为同一个类型的对象,可能在不同地方分配,但是最终还会调用到公用的接口再触发底层分配,基于这种情况,肯定有很多记录的地址是相同的。我们可以先把记录的地址全部取出来,然后进行合并,合并之后数量会少很多。
  • atos解析地址可以同时解析多个,因为本身启动atos程序就有开销,如果一次解析多个可以避免频繁启动atos。具体多少个,最好是直接用系统参数上限的个数,系统参数个数可以通过 os.sysconf(“SC_ARG_MAX”) 获取到。
  • 由于解析地址本身是互相不依赖的,我们可以开启多线程解析。

采取以上步骤之后,我这边40万+的地址解析时间从原来的6小时+,减少到了60秒左右。

到此数据都准备好了。

展示工具

我们直接写个展示工具,针对展示数据的工具我们可以参考Profiler,ProfilerAnalyzer工具的布局。先细化下工具的要求:

  • 能看到每帧分配的所有对象列表,每个对象类型的分配聚合在一起,能看到该对象的所有分配堆栈来源。
  • 能看到整个单局分配的TOP30,这样便于全局知道我们内存是由哪些对象的分配造成膨胀的。
  • 能够对比两局不同的内存采样数据,便于对比出内存差异,查出内存增长点。

基于以上需求,开发出来的工具如下:

Frame视图模式

MonoTracer统计了每一帧IL2CPP对象分配,每个类型的对象分配都会记录所有分配来源,每个来源都有详细的调用堆栈,根据这个堆栈可以定位到业务层的分配调用。

TopN视图

工具可以列出TopN的分配详情,根据这个分配排行可以来优化IL2CPP内存。

Compare视图

工具可以列出TopN的分配详情,根据这个分配排行可以来优化Mono内存。

有了工具之后,就可以很方便的定位出来内存过高的来源了。


阅读全文 »

Unity内存(二) 内存快照

发表于 2024-11-15

Unity的Mono内存有两个指标很关键,一个是 Used Total 另外一个是 Reserved Memory。 这个可以从 Profiler 的 Memory 项中可以看到:

1

Unity2019.4.18f1 版本的Profiler截图

从脚本里面我们可以通过Unity提供的API可以获取到这些内存大小:

  • Mono Used Total :GetMonoUsedSizeLong 返回当前正在使用的Mono内存总和,包括存活的对象内存和已经不被引用但是还没有被GC回收的对象内存。
  • Mono Reseved Total : GetTotalReservedMemoryLong Reserved内存表示当前Unity向系统总共申请分配的内存,其中包括正在使用的内存(Mono Used Total)、预申请的内存、已经使用过并且回收的内存。

Mono内存问题原因很简单就是分配的太多了,但是出现问题主要有两个原因导致的:

  • 释放的太少,导致内存占用过高
  • 虽然释放的很多,但是因为内存碎片的问题(上一篇已经介绍),释放的内存用不上,导致内存占用过高。

针对以上两个不同的原因,我们分别需要用不同的方式来定位解决:

  • 释放的太少问题 - 当前内存占用过高,Dump出当前的内存,查看内存中的对象分配,就可以定位出来哪些对象分配的太多,并且没有释放了。
  • 内存碎片的问题 - 内存碎片问题就需要追踪历史的内存分配,需要关注一些频繁分配对象的代码,尤其是数量多但是单个对象内存占用小的情况。这里Dump显然不合适,Dump功能只能看某一帧内存详情,并且Dump一下内存本身会比较耗时导致游戏会卡住,所以我们不可能把单局的每一帧都Dump出来。所以我们需要记录下每帧内存的情况来源以及分配大小,类似 Profiler中的CPU消耗列表一样。

下面我们针对两种方式做详细的介绍。

Memory Dump

Unity提供了一个API用来Dump内存:

1
2
3
4
5
6
7
8
9
10
11
public static void TakeSnapshot(string path, Action<string,bool> finishCallback, Unity.Profiling.Memory.CaptureFlags captureFlags);

[Flags]
public enum CaptureFlags : uint
{
  ManagedObjects = 1, //托管内存(Mono内存)
  NativeObjects = 2,
  NativeAllocations = 4,
  NativeAllocationSites = 8,
  NativeStackTraces = 16, // 0x00000010
}

根据指定的枚举类型,可以Dump出对应的内存数据,我们常用的HeapExplore(最新的还有MemoryProfiler)内存Dump工具就是用的这个API。Dump Native内存比较简单,因为Native对象都在引擎里面自己分配自己维护的,只需要把分配对象列表输出即可(看HeapExplore解析的代码也很简单)。Dump Mono内存就相对复杂了,因为il2cpp之后,设备上跑的是C++,内存申请是业务层申请的,引擎层是一层类似中间层的组织管理,最终的分配是通过 bdwgc(这个流程上一篇已经介绍)的,分配内存由Mono托管,所以Mono虚拟机里面才有完整的内存对象列表。

问:既然HeapExplore可以满足Dump的需求了,那是不是直接用HeapExplore工具,不用去管具体怎么Dump的。

答:如果你需要用到Dump的频率很少,并且对里面的技术细节不感兴趣,那是的。 但是一般项目中会涉及到版本内存的日常监控,开发过程中内存监控,就需要更加高效,更加自动化的流程。比如自动化流水线直接输出Mono内存的差异列表(版本间的对比数据),直接提单到对应的程序,整个自动化过程不需要人工干预。自己了解了Dump的工作原理和细节之后就可以定制化这种功能。

关于TakeSnapshot输出的内存Dump文件,在Unity Documents基本上搜不到有价值的信息(也可能是我没找到)。所以最直接的方式是直接看HeapExplore代码,通过HeapExplore代码来反推Dump文件的信息,了解Dump的细节(这里有个奇怪的点是HeapExplore的作者是怎么了解到Dump文件的数据内容的,莫不是有源码)。那就先从HeapExplore工具展示的内容,再到HeapExplore的代码,最后了解Dump的原理。首先我们看下HeapExplore里面Mono内存的统计:

image-20241118210547014

可以看到HeapExplorer可以把当前帧每种类型的对象都列出来,并且每种类型的对象分配的数量和大小都有统计。了解功能之后我们直接看解析TakeSnapshot文件的代码,看这个C#对象列表是怎么解析出来的。首先我们分析下 TakeSnapshot出来的文件对象(只列出Mono内存相关):

1
2
3
4
5
6
7
8
9
//PackedMemorySnapshot.cs
public class PackedMemorySnapshot : IDisposable
{
    /// All GC handles in use in the memorysnapshot.
    public GCHandleEntries gcHandles {get; internal set;}
    
    // Descriptions of all the managed types that were known to the virtual machine when the snapshot was taken.
    public PackedManagedType[] managedTypes = new PackedManagedType[0];
}

下面逐步解析下 PackMemorySnapshot 的成员变量所存储的数据。

gcHandles

GChandle提供用于从非托管内存访问托管对象的方法。实现的方式是通过GCHandle给指定对象分配句柄,分配句柄之后的对象可以阻止GC收集此对象,避免对象没有被引用时被GC回收,直到调用Free接口才会释放。这样非托管对象引用托管对象时,控制托管对象的生命周期,防止托管对象被GC回收。可以做个简单的测试来看下GCHandle的作用:

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
31
32
33
34
35
36
37
38
39
public class GCHandleTest : MonoBehaviour
{
    private void Start()
    {
        FreeGCHandleObject();
    }
    public class TestGCHandleObject
    {
        public string Name;
        ~TestGCHandleObject()
        {
            Debug.LogError("~TestGCHandleObject " + Name);
        }
    }
    //有GCHandle,有Free,new的对象在Free之后的GC会立即销毁,不会等到函数调用结束。
    private void FreeGCHandleObject()
    {
        Debug.LogError("STEP0.BEGIN----------");
        var ptr = NewGCHandleObject();
        CallGCCollect();
        //备注1. 可以通过地址获取到Handle,并且可以获取到对象 handle.Target
        var handle = GCHandle.FromIntPtr(ptr);
        Debug.LogError("STEP1.FREE-----------");
        //备注2
        handle.Free();
        CallGCCollect();
        Debug.LogError("STEP2.END-------------");
    }
	//创建一个GCHandle Pinned的对象
    private IntPtr NewGCHandleObject()
    {
        var newObj = new TestGCHandleObject();
        newObj.Name = "Hello GCHandle!";
        var gcHandle = GCHandle.Alloc(newObj);
        //备注3.可以直接获取到对象的地址
        IntPtr newObjectPtr = GCHandle.ToIntPtr(gcHandle);
        return newObjectPtr;
    } 
}

输出结果:

1
2
3
4
STEP0.BEGIN----------
STEP1.FREE-----------
~TestGCHandleObject Hello GCHandle!
STEP2.END-------------

可以看到,GCHandle可以直接获取到对象的地址(代码备注3),根据地址可以再次获取到对象(代码备注1)。这样为托管对象访问非托管对象提供了便利,Int32类型的地址在托管内存和非托管内存是可以直接传递的,不需要拷贝,内存是对齐的。

怎么获取到内存中所有的 GCHandle对象的?既然Unity是用的 il2cpp,会不会是il2cpp已经提供的接口,不然Unity引擎本身理论上也是不会存储这些数据的。看了下il2cpp的源码,确实il2cpp提供了获取接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void GCHandle::WalkStrongGCHandleTargets(WalkGCHandleTargetsCallback callback, void* context)
{
    lock_handles(handles);
    const GCHandleType types[] = { HANDLE_NORMAL, HANDLE_PINNED };

    for (int gcHandleTypeIndex = 0; gcHandleTypeIndex < 2; gcHandleTypeIndex++)
    {
        const HandleData& handles = gc_handles[types[gcHandleTypeIndex]];

        for (uint32_t i = 0; i < handles.size; i++)
        {
            if (handles.entries[i] != NULL)
                callback(static_cast<Il2CppObject*>(handles.entries[i]), context);
        }
    }
    unlock_handles(handles);
}

代码在 GCHandle.cpp,可以看到GCHandle对象存储在数组链表结构的 gc_handles中, 根据这个函数可以遍历出来 所有GCHandle的对象。

总结下,GCHandle 持有的托管对象是不会被释放的,直到调用Free。那么PackedMemorySnapshot里面的 gcHandles 数据即在内存中的所有GCHandle勾住的托管对象。

managedTypes

managedTypes数组的元素类型为 PackedManagedType,这个对象保存了一个非常重要的信息 “对象类型地址”,看下定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public struct PackedManagedType
{
		// An array containing descriptions of all fields of this type.
        public PackedManagedField[] fields;
	 	
	 	// Size in bytes of an instance of this type. If this type is an arraytype, this describes the amount of bytes a single element in the array will take up.
        public System.Int32 size;

        // The address in memory that contains the description of this type inside the virtual machine.
        // This can be used to match managed objects in the heap to their corresponding TypeDescription, as the first pointer of a managed object points to its type description.
        public System.UInt64 typeInfoAddress;

        // The typeIndex of this type. This index is an index into the PackedMemorySnapshot.typeDescriptions array.
        public System.Int32 managedTypesArrayIndex;
        
        //...省略很多代码
}

typeInfoAddress 就是保存的对象类型地址。C# 代码在 il2cpp 之后,代码的类型、名称、父类、方法等等这些信息都会保存一个叫做 Il2CppClass的对象中,这使得编写的C#代码时使用的反射,泛型等C#语言的特性能在 il2cpp 之后的C++代码得以继承。可以看到 Il2CppClass对象的定义(上一篇已经讲过):

1
2
3
4
5
6
7
8
9
10
typedef struct Il2CppObject
{
    union
    {
        //类型信息
        Il2CppClass *klass;
        Il2CppVTable *vtable;
    };
    MonitorData *monitor;
} Il2CppObject;

类型信息存放在对象的首地址,这也使得只要我们有类型信息对象 klass,这样我们就可以获取到对象的地址了。managedTypes 保存了所有类型信息,但是不是说直接遍历 managedTypes 能获取到所有对象。因为Il2CppObject中的klass对象是共享的,也就是只要类型相同,创建出来的Il2CppObject对象的 klass指向的是同一个对象,这个可以从Il2Cpp中的代码看到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//MetadataCache.cpp
Il2CppClass* il2cpp::vm::MetadataCache::GetTypeInfoFromTypeIndex(TypeIndex index, bool throwOnError)
{
    if (index == kTypeIndexInvalid)
        return NULL;

    IL2CPP_ASSERT(index < s_Il2CppMetadataRegistration->typesCount && "Invalid type index ");

    //如果已经存在,则直接返回类型对象
    if (s_TypeInfoTable[index])
        return s_TypeInfoTable[index];

    const Il2CppType* type = s_Il2CppMetadataRegistration->types[index];
    Il2CppClass *klass = il2cpp::vm::Class::FromIl2CppType(type, throwOnError);
    if (klass)
    {
        il2cpp::vm::ClassInlines::InitFromCodegen(klass);
        s_TypeInfoTable[index] = klass;
    }

    return s_TypeInfoTable[index];
}

读了HeapExplorer的解析代码,可以通过 managedTypes 获取到所有的成员(fields),然后再遍历静态成员(PackedManagedType的 fields)来索引引用的对象,这样一直遍历完所有的对象,相当于把所有被引用的对象都能遍历出来,这些被引用的对象就是内存中还存活的对象。这里我们可以总结出来:

1
托管对象集 = GCHandle对象集 + 静态对象及其被引用的所有对象集

那么我们就有了所有的托管对象了,这个也是HeapExplorer显示的托管对象列表的数据集。同样的,我在 il2cpp 的代码中找到了获取所有类型对象的接口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//MemoryInfomation.cpp
void ReportIL2CppClasses(ClassReportFunc callback, void* context)
{
    const AssemblyVector* allAssemblies = Assembly::GetAllAssemblies();
    for (AssemblyVector::const_iterator it = allAssemblies->begin(); it != allAssemblies->end(); it++)
    {
        const Il2CppImage& image = *(*it)->image;
        for (uint32_t i = 0; i < image.typeCount; i++)
        {
            Il2CppClass* type = MetadataCache::GetTypeInfoFromTypeDefinitionIndex(image.typeStart + i);
            if (type->initialized)
                callback(type, context);
        }
    }
	//...省略一些代码
}

总结下,拥有managedTypes数据,可以遍历到所有静态对象以及被静态对象引用的对象,再加上 GCHandle对象集就是完整的 托管对象集合了。

到这里我们已经了解了 TakeSnapshot采集的托管对象数据结构,也了解了HeapExplorer怎么解析的托管内存Snapshot数据了。我们也可以自定定义采集数据做自动化监控功能:

image-20241118210547014


引用:

  1. Total System Memory

  2. The Truth About GCHandles

  3. Universal Windows Platform: Debugging on IL2CPP Scripting Backend

阅读全文 »

Unity内存(一)了解Mono内存

发表于 2024-09-22

目录

  • 什么是IL2CPP
  • Mono内存分配流程
  • BDWGC

目录的主题之间存在相互依赖,前面三个主题是为了了解内存,第四个 Mono 内存监控是基于前三个主题的知识做的监控工具。

注:1. 本文的 Mono 内存全部指 Unity 开启 IL2CPP之后构建的手机包运行时的 Mono 内存。

​ 2. BDWGC 指 Boehm-Demers-Weiser Garbage Collector,用在标题是全部为大写,用在文中时方便阅读我全部小写。

什么是IL2CPP

引用Unity文档中说的:

The IL2CPP (Intermediate Language To C++) scripting backend is an alternative to the Mono backend. IL2CPP provides better support for applications across a wider range of platforms. The IL2CPP backend converts MSIL (Microsoft Intermediate Language) code (for example, C# code in scripts) into C++ code, then uses the C++ code to create a native binary file (for example, .exe, .apk, or .xap) for your chosen platform.

对Unity来说,就是在项目构建时,将从C#编译后的IL代码转换为Cpp代码,用来替代Mono。下面我们用一个例子来说明下。

首先我们在C#中实现了名字为 BehaviourBase 的类,如下:

1
2
3
4
5
// BehaviourBase.cs 注意:这里的MonoBehaviour是C#的类
public class BehaviourBase : MonoBehaviour
{
    public int m_guid = 0;
}

构建iOS包之后,我们可以从Xcode工程里面找到C# IL2CPP之后的代码,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Assembly-CSharp.cpp  BehaviourBase
struct  BehaviourBase_t75F1477520D10A275C6158D6024AAD65240A0A12  : public MonoBehaviour_t4A60845CF505405AF8BE8C61CC07F75CADEF6429
{
public:
    // System.Int32 BehaviourBase::m_guid
    int32_t ___m_guid_4;
}

// 可以看到C#的MonoBehaviour变成了空壳
// 因为C#层的MonoBehaviour本身就是C++MonoBehaviour的壳子
struct  MonoBehaviour_t4A60845CF505405AF8BE8C61CC07F75CADEF6429  : public Behaviour_tBDC7E9C3C898AD8348891B82D3E345801D920CA8
{
public:
public:
};

再看下追溯下MonoBehaviour的基类继承关系,最终我们可以追溯到MonoBehaviour继承自Object类:

1
2
3
4
5
6
7
8
// Assembly-CSharp.cpp UnityEngine.Object
struct  Object_tAE11E5E46CD5C37C9F3E8950C00CD8B45666A2D0  : public RuntimeObject
{
public:
    //这个 ___m_CachedPtr_0存储的就是C#绑定的C++对象的地址
    intptr_t ___m_CachedPtr_0;
    //省略
};

对应的C#中的UnityEngine的Object类:

1
2
3
4
5
6
// Object.cs
public partial class Object
{
    IntPtr   m_CachedPtr;
    //省略
}

对比可以看到,IL2CPP之后,Object类改为继承自了RuntimeObject,那么RuntimeObject是什么呢?我们可以从IL2CPP的代码中找到定义,定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#if RUNTIME_MONO /* 是否是用mono,如果否,则用IL2CPP */
typedef MonoObject RuntimeObject;
#else
typedef Il2CppObject RuntimeObject;
#endif

//Il2CppObject保存了C#对象的类型信息, 虚函数等信息, 
//用于模拟C#运行时对象类型信息的查询、方法调用和多态性等操作。
typedef struct Il2CppObject
{
    union
    {
        // 保存类型信息,包含C#类的元数据(类名、方法等)
        Il2CppClass *klass;
        // 保存类的虚函数表,用于支持C#类的多态
        Il2CppVTable *vtable;
    };
    MonitorData *monitor;
} Il2CppObject;

根据继承关系我们可以得知,C#代码在Il2CPP之后,对象的内存应该是这样的:

1
2
3
4
5
6
7
8
+--------------------+
| Il2CppObject       |
| - klass/vtable 4b  |
| - monitor      4b  |
+--------------------+
| UnityCSharpClass   |
| - xxxField         |
+--------------------+

此外因为有了 klass / vtable 数据,对应的Cpp对象就可以获取对象的类型信息,可以实现C#的反射这类功能了。

总结下整个流程如下:

IL2CPP流程图

Mono内存分配流程

了解了IL2CPP的过程,我们现在回到内存部分。我们都知道C#代码运行时内存是由mono运行时托管的,开发者在创建对象的时候不需要自己显示分配内存和释放内存,使用的是GC。IL2CPP之后,内存则由il2cpp运行时托管,使用的是BoehmGC。

接下来用一个例子来演示下il2cpp的内存托管过程,首先如果在C#层调用如下代码创建一个对象:

1
2
3
4
5
6
7
// Test_Il2Cpp.cs 
private static void CreateNode()
{
    //这里new Node对象, 内存是有mono的gc托管
    Node newNode = new Node();
    //do samething...
}

IL2CPP之后,我们看上面代码中的 new Node() 代码:

1
2
3
4
5
6
7
// Assembly-CSharp.cpp 
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void NodeFactory_CreateNode_m5E30EE5A6D660B15B3B6AADAAF00D416B84BFE12 (
{
    //省略
    Node_txxx * L_7 = (Node_txxx*)il2cpp_codegen_object_new(Node_txxx_il2cpp_TypeInfo_var);
    Node__ctor_m72145ADDB949F121912EEA2AAB5138ADEAF80EE4(L_7, /*hidden argument*/NULL);
}

可以看到 new Node 转换成了 il2cpp_codegen_objec 函数来创建,该函数最终会调用到NewAllocSpecific 再看下函数的实现:

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
//il2cpp-codegen-il2cpp.cpp
Il2CppObject* Object::NewAllocSpecific(Il2CppClass *klass)
{
    Il2CppObject *o = NULL;
    //省略...
    if (!klass->has_references)
    {
        //调用 ALLOC_PTRFREE分配内存
        o = NewPtrFree(klass);
    }
//Unity2019这个宏是默认开启的, 宏是控制否启用 GC 描述符。
//述符的作用是帮助垃圾回收识别托管对象的引用,可以更准确的标记和回收对象
//在iOS平台测试是启用GC描述符的,其他平台没有测试。
#if IL2CPP_HAS_GC_DESCRIPTORS
    else if (klass->gc_desc != GC_NO_DESCRIPTOR)
    {
        //调用 ALLOC_TYPED 分配, 包含了klass这个Il2CppClass的Type信息
        o = AllocateSpec(klass->instance_size, klass);
    }
#endif
    else
    {
        //调用 ALLOC_OBJECT 分配,也包含了Il2CppClass的Type信息
        o = Allocate(klass->instance_size, klass);
    }
    //省略...
}

NewAllocSpecific 不同的分配函数最终都会调用到 Object.cpp中的内存分配宏定义(如下代码,内存分配接口收敛在这里对后面内存监控很有用), 这三个宏定义的是 bdwgc 的分配接口。到这里我们可以看到,IL2CPP对象创建的内存分配直接是被 bdwgc 接管的。bdwgc 最终在在分配的是调用系统接口分配的是系统的虚拟内存,这个后面再讲。

1
2
3
4
5
6
7
8
9
10
//Object.cpp

// NewPtrFree 调用, 定义在 malloc.c 中, 方法GC_malloc_kind_global
#define ALLOC_PTRFREE(obj, vt, size) do { (obj) = (Il2CppObject*)GC_MALLOC_ATOMIC ((size)); (obj)->klass = (vt); (obj)->monitor = NULL;  } while (0)

// Allocate 调用, 定义在 malloc.c 中, 方法GC_malloc_kind_global
#define ALLOC_OBJECT(obj, vt, size) do { (obj) = (Il2CppObject*)GC_MALLOC ((size)); (obj)->klass = (vt); } while (0)

// AllocateSpec调用, 定义在 gcj.mlc.c 中, 方法为 GC_core_gcj_malloc
#define ALLOC_TYPED(dest, size, type) do { (dest) = (Il2CppObject*)GC_gcj_malloc ((size),(type)); } while (0)

总结下整个流程图如下:

BDWGC

了解 bdwgc 的内存分配规则,我们才能知道Unity的Mono内存增长的规则。 bdwgc 指的是 Boehm-Demers-Weiser Garbage Collector,bdwgc 的作用主要是自动内存分配和释放的管理。下面讲内存的 ‘分配’ 和 ‘释放’ 两部分分别讲解下。

BDWGC 内存分配

bdwgc 的内存分配主要关注三个数据结构:

  • GC_hblkfreelist : 维护了所有预分配或者可用的内存块,使用数组链表结构管理。这些内存是直接调用系统内存分配接口分配好的虚拟内存。
  • ok_freelist : 维护了小块内存对象,使用数组链表结构来管理。这里维护的内存都是指向GC_hblkfreelist 的内存,这里只是做一个快速分配的管理。
  • GC_obj_kinds:维护不同类型内存的数组,每个数组元素对象都包含一个ok_freelist。内存主要包括:
    • NORMAL:普通的内存性,包含指针和非指针的数据,比如IL2CPP 内存分配流程图中的 GC_malloc分配的内存。
    • PTRFREE:不包含指针的对象。 GC_malloc_atomic分配的内存,这些对象在垃圾回收的过程中不需要扫描,比如整形数据,元素是值类型。

看下 ok_freelist 和 GC_hblkfreelist 定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//GC_obj_kinds的每个元素里面都定义了一个 ok_freelist 数组链表
GC_EXTERN struct obj_kind {
   void **ok_freelist;  /* Array of free list headers for this kind of  */
                        /* object.  Point either to GC_arrays or to     */
                        /* storage allocated with GC_scratch_alloc.     */
   // 省略...
} GC_obj_kinds[MAXOBJKINDS];

// N_HBLK_FLS 的大小为 60
struct hblk * GC_hblkfreelist[N_HBLK_FLS+1] = { 0 };
                             /* List of completely empty heap blocks */
                             /* Linked through hb_next field of      */
                             /* header structure associated with     */
                             /* block.  Remains externally visible   */
                             /* as used by GNU GCJ currently.        */
struct hblk {
    char hb_body[HBLKSIZE];
};

下面用一个分配流程来描述下内存分配的流程,流程不是很复杂:

分配过程中有两点需要注意:

  • 不管是 ok_freelist 还是 GC_hblkfreelist 内存都是 4 的整数倍,这个在 bdwgc 的规定,当业务创建对象分配内存的时候,如果不是 4 的整数倍,在 bdwgc 分配的时候,会先根据申请的大小向上匹配到 4 整数倍内存大小,这个大小就是 bdwgc 中实际分配的内存。

  • GC_hblkfreelist 在分配一块内存的时候会选择性拆分几块,一块内存回收的时候会尝试合并下前序和后序内存块。

基于以上两点会导致我们常说的内存碎片问题,因为实际分配的内存大部分情况下是大于申请的内存的,这样就造成多余的这部分内存用不上。而且大内存块拆分成小内存块之后,造成内存池中大内存块逐步减少,业务层程序再次分配大内存块又不得不向系统请求分配,尽管现在池子当中还有很多可用但是不连续的内存。

BDWGC 内存释放

bdwgc 内存是释放有两种方式:

  • 主动调用:业务层调用 GC_gcollect(void) 函数触发GC,Unity中对应C#接口的 GC.Collect()。
  • 自动调用:在请求内存分配时,如果内存池的内存不够了触发 GC。

触发GC有两种模式:增量和全量。代码里面是通过 GC_incrmental 来区分的。可以看下请求内存分配时触发GC的区别:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
GC_INNER ptr_t GC_allocobj(size_t gran, int kind) {
    //1. 从ok_freelist中取
    void ** flh = &(GC_obj_kinds[kind].ok_freelist[gran]);
    //省略...
    //ok_freelist中获取失败
    while (*flh == 0) {
        //省略很多代码... (这里有次增量gc和重新分配)
        if (NULL == *flh) {
            //2. 去内存池 GC_hklbfreelist中取. 3.没有取不到从系统分配
            GC_new_hblk(gran, kind);
            if (GC_incremental && GC_time_limit == GC_TIME_UNLIMITED && !tried_minor && !GC_dont_gc) {
                //增量GC
                GC_collect_a_little_inner(1);
            } else {
                //全量GC
                if (!GC_collect_or_expand(1, FALSE, retry)) {/*省略*/}
          }}}
    return (ptr_t)(*flh);
}

上面降到的是调用层面的区别,对于增量和全量GC本身内存的区别在于,增量GC是把一次全量的GC分散到多帧执行,避免一次GC卡主太长的时间。全量GC就是执行一次,会从内存对象 GC_static_roots 做一次完成的标记扫描回收的过程。看下两者的代码区别:

1
2
3
4
5
6
7
8
9
10
11
//alloc.c 增量回收
GC_INNER void GC_collect_a_little_inner(int n) {
    //省略...
    for (i = GC_deficit; i < 10 * n; i++) {
        //标记阶段
    	if (GC_mark_some(NULL))
        	break;
    }
    //清理回收
    GC_finish_collection();
}
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
//alloc.c 
GC_INNER GC_bool GC_collect_or_expand(word needed_blocks, GC_bool ignore_off_page, GC_bool retry){
    //尝试回收内存。
    gc_not_stopped = GC_try_to_collect_inner(
                        GC_bytes_allocd > 0 && (!GC_dont_expand || !retry) ?
                        GC_default_stop_func : GC_never_stop_func);
    if (gc_not_stopped == TRUE || !retry) {
        // 执行成功的话直接返回, 注意 retry GC_allocobj调用过来都是为FALSE的
        return(TRUE);
    }
    //回收内存失败的话,直接扩容
    if (!GC_expand_hp_inner(blocks_to_get)
        && (blocks_to_get == needed_blocks
            || !GC_expand_hp_inner(needed_blocks))) {/*省略*/}
}
//如果是手动调用GC_gcollect,内部调用的接口就是此接口
GC_INNER GC_bool GC_try_to_collect_inner(GC_stop_func stop_func)
{
    //清除所有引用标记
    GC_clear_marks();

    //暂停所有线程(不包括GC自己),遍历GC_static_roots标记所有的未引用对象(GC_mark_some),结束之后会恢复暂停的线程
    if (!GC_stopped_mark(stop_func)) {/*省略*/}

    //回收内存(会清空ok_freelist,重新构建ok_freelist)
    GC_finish_collection();
}

上面提到一个概念,在回收内存之前会先遍历 节点来标记未被引用的对象。这里解释下 GC_static_roots,先看下定义:

1
2
3
4
5
6
7
8
9
10
11
//gc.priv.h GC_static_roots的定义
#define GC_static_roots GC_arrays._static_roots
struct roots _static_roots[MAX_ROOT_SETS];

struct roots {
	ptr_t r_start;/* multiple of word size */
	ptr_t r_end;  /* multiple of word size and greater than r_start */
    #if !defined(MSWIN32) && !defined(MSWINCE) && !defined(CYGWIN32)
    	struct roots * r_next;
	#endif
}

GC_static_roots 的构建是在库加载的时候,库加载的时候会将里面定义的全局变量和静态变量添加进来。回收时在 GC_mark_same 里,如果是全量GC,会将 GC_static_roots的所有对象添加到内存回收的标记栈里:

1
2
3
4
5
6
7
8
//mark_rts.c
GC_INNER void GC_push_roots(GC_bool all, ptr_t cold_gc_frame GC_ATTR_UNUSED)
	/* Mark everything in static data areas.*/
	for (i = 0; i < n_root_sets; i++) {
        GC_push_conditional_with_exclusions(GC_static_roots[i].r_start, GC_static_roots[i].r_end, all);
	}
	//...
}

到这里 bdwgc 的分配和释放规则我们已经了解。


引用:

  1. [Unity - Manual: Mono overview](https://docs.unity3d.com/Manual/Mono.html)

  2. Unity3D托管堆BoehmGC算法学习-内存分配篇

  3. Unity3D托管堆BoehmGC算法学习-垃圾回收篇

  4. GitHub il2cpp各个版本的源码整理

  5. MemoryProfiler Package Manual

  6. Unity Profiler Manual

  7. c/c++ backtrace打印函数调用栈

  8. iOS dSYM 文件 & 符号化

  9. 利用dwarfdump从dsym文件中得到symbol

阅读全文 »
1 2 … 9
© 2025 yiliangduan@qq.com