打开网易新闻 查看精彩图片

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!

这是侑虎科技第2008篇文章,感谢作者其乐陶陶供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:

https://www.zhihu.com/people/jun-yan-76-80

前言

Resources.UnloadUnusedAssets是Unity项目里一个绕不开的卡顿源:每次切场景,它都会在主线程上同步跑完一整套Native资源回收,200-300ms的耗时几乎是常态,低端机甚至到秒级别。而它名称标注的“异步”并不能把任何工作真正挪出主线程。

下面分四步拆:执行机制、耗时构成、可优化的环节、具体的改造方法。最终给出两套互不冲突的优化方案,实测能把整体耗时减少70%左右

一、Resources.UnloadUnusedAssets的作用

1.1 它要解决的问题

写过Unity的都遇到过这种代码:

GetComponent 

 ().material.color = Color.red; 

这一行看着无害,实际触发了一次Material Clone。Renderer.material的Getter不直接返回Shared Material,而是检查“这个Renderer是否已经持有自己的Material实例”,没有就Clone一份。Clone出来的实例,生命周期挂在Renderer 上。

问题来了:Renderer销毁了,Clone出来的Material谁来回收?C# 这边没人引用它了,但它是个NativeObject,GC.Collect()管不到。

Resources.UnloadUnusedAssets就是来收这个尾的。它扫一遍所有还活着的NativeObject,找出那些“既不被C# 持有、也不在场景里”的资源,挨个释放。

打开网易新闻 查看精彩图片

1.2 还在哪里被触发

主动调用是一种,但更隐蔽的是:每次SceneManager.LoadScene(非Additive模式),引擎自动调一次。这是写在PreloadManager里的固定流程,关不掉。也就是说,只要项目用Unity切场景,这个API就会规律性地造成固定卡顿开销。

1.3 规避的做法

其实上面两个例子,UnloadUnusedAssets的调用并非完全不可规避:Clone出来的Material可以自己持有引用手动销毁,切场景的那次触发也可以稍微改造引擎去掉。但实际项目有历史负担,也要控制内存峰值,在我面对的业务场景下回避不了,只能优化。

二、它做了什么:执行流程与异步真相

打开网易新闻 查看精彩图片

一次UnloadUnusedAssets耗时拆解

UnloadUnusedAssets的内部流程,可以打这么个比方:它像物业管理员搞一次小区普查,目的是腾出空房子。整个流程分五步,每一步对应一个具体阶段。

2.1 五个阶段

第一步:列住户名册:FindAllLiveObjects打开物业系统,把所有登记在册的住户(Object::IDToPointerMap里的所有对象)抄一遍,生成liveObjects[]。

第二步:确认门牌种子:MarkSceneRootsAndReduceLiveObjects找出哪些住户是“明确不能动的” —— 场景里挂着的GameObject、MonoBehaviour、AssetBundle等,以及MarkManagerRoots找出的全局Manager。这些标成Root,索引加入initialNeedsProcessing队列,作为追踪起点。

第三步:建索引:CreateObjectToIndexMappingFromNonRootObjects为剩下的对象建一张“InstanceID → 索引”的哈希表。后面要频繁通过InstanceID找对象,有这张表能在O(1)查到。

第四步:把C# 那边的根也加进来,然后挨家敲门:MarkManagedStaticVariableRoots + MarkAllDependencies

这一步是两个连续动作,合起来占全流程接近九成:

4a.MarkManagedStaticVariableRoots(=GC.MarkStaticDependencies,~49%):调IL2CPP的Liveness::FromStatics(),遍历所有C# 静态字段、递归walk整个托管堆,把发现的Native对象加入needsProcessing队列。这一段全程在C# 托管堆里跑,没碰Native图遍历。

4b.MarkAllDependencies(=GC.MarkNormalDependencies,~39%):一次性消费两个队列 —— initialNeedsProcessing(场景根/Manager根)和needsProcessing(FromStatics新发现的根),递归MarkDependencies,把所有可达Native对象Marked=1。

第五步:封空房:CleanupUnusedObjects扫一遍,所有没被标记到的对象,挨个释放Native内存。

2.2异步的真相:这个API不是真异步

到这里有人会问:这个API不是异步的吗?返回的是AsyncOperation,这么大的耗时不是应该挪到子线程去跑、不阻塞主线程吗?不是。它的“异步”只是延迟一帧

  • 调用时,把操作加入PreloadManager队列,立刻返回

  • 下一帧的PreloadManager.UpdatePreloading主线程执行,触发IntegrateMainThread()

  • IntegrateMainThread里把整个GarbageCollectSharedAssets()跑完 —— 主线程独占

  • 子线程那边只做了一件小事:关一下文件流,占比可忽略

也就是说,所谓“异步”,只是把卡顿推迟到下一帧;主线程并没有逃脱阻塞。

2.3 案例

在一个MOBA项目中,Loading末尾调了一下Resources.UnloadUnusedAssets,目的是把Loading期间临时加载的资源回收一下,避免战斗场景内存压力大。调用是fire-and-forget的:

// 加载各种资源
DoBattleLoading();
// Loading 末尾
Resources.UnloadUnusedAssets();
// 接下来发送自己加载结束的消息
SendMarkLoadingComplete();

听起来很合理。但上线后发现一件诡异的事:不同玩家入局时间不一样。有的玩家进战斗场景立刻能动,有的玩家会卡一会儿才能动。

排查到最后定位到这个API。原因是:UnloadUnusedAssets的Integrate时机依赖PreloadManager的调度,它什么时候在主线程跑,引擎说了算,Integrate一般会推迟执行。

结果就是Integrate漏掉了战斗场景刚开始的那一帧。这一帧主线程被GC阻塞了200ms,玩家看到的就是“刚进入战斗就卡了一下”。这种漏出的发生概率和设备性能、当前帧的工作负载、场景切换的Loading速度都有关。低端机更容易漏掉战斗场景,高端机偶尔也会中招,一场对战10个玩家,每个人入局时间都不一致。

2.4 把控制权拿回主线程

把引擎里现成的WaitForAllAsyncOperationsToComplete暴露给脚本层就够了。这个方法在PreloadManager一直存在,只是没暴露到C#。也可以用协程轮询AsyncOperation.isDone,等做完再继续。

Loading末尾改成:

// Loading 末尾
Resources.UnloadUnusedAssets();
// 主线程同步等到所有 PreloadManager 队列里的异步操作完成
WaitForAllOperationsToComplete();
// 此时 GC 已经跑完,可以安全跳战斗
SendMarkLoadingComplete();

代价是Loading末尾多卡200ms,但卡在Loading玩家心理上能接受(进度条还没满),卡在战斗里不能接受(对战已经开始了)。这个改动不解决“为什么慢”,但解决了“什么时候慢”,把不可控的延迟变成可控的延迟。

「慢」本身怎么解,下一节开始处理。

三、标记跳过:让扫描器学会绕路

前面我们提到,第四步两个子项里,MarkStaticDependencies(4a)单独占总耗时35-45%,是最大的单点热点

为什么4a这么慢? 它要做的是IL2CPP层面的Liveness扫描:

  • 拿到所有有静态字段的Class

  • 每个Class的每个静态字段,如果是引用类型,就读出来

  • 引用指向的对象,继续遍历它的所有字段

  • 字段里的对象是数组,数组每个元素再遍历

  • 一直递归下去,直到没有新发现

时间复杂度大概是O(类数量×静态字段数×引用深度×数组元素数)。一旦项目代码量上来,这个数字就爆炸了。实测一个中等规模MOBA项目,单这一步就要50-100ms。

我的直觉是,我们游戏C# 层存在大量配置表对象,这块确保无Native对象,就没必要扫,一定是有明显的优化空间的,所以,可以进行一些标记,让扫描器看到不必扫的字段就跳过

听起来简单,做起来要绕过三道坎。

3.1 第一道坎:运行时判断不能慢

最朴素的想法是给字段挂个[SkipLiveness] Attribute,扫描时查一下:

if (Reflection::HasAttribute(field, SkipLivenessAttribute))
continue;

HasAttribute涉及二分查找,O(log n) 一次。每个字段每次GC都查一遍,一千个类、几万个字段,加起来就是几十毫秒 —— 优化没做出来,先把扫描搞慢了。

要做就要做到O(1)。

3.2 第二道坎:在哪里挂这个标记位

IL2CPP里描述每个字段的元数据是Il2CppType结构,其中有个Attrs字段,16位:

unsigned int attrs : 16;  // 字段属性 bitmask

这16位定义在il2cpp-tabledefs.h里,有STATIC、LITERAL、HAS_DEFAULT等等。但其中有几个位是闲置的:

打开网易新闻 查看精彩图片

借位3,定义成:

#define FIELD_ATTRIBUTE_SKIP_LIVENESS  0x0008

扫描时一个位与判断就完事:

if (field->type->attrs & FIELD_ATTRIBUTE_SKIP_LIVENESS)
continue;

零内存开销,扫描时多一次判断。

3.3 借位安全吗:TaggedPointer同源思想

借空闲位不是新发明,业界用了几十年了。最经典的例子是IL2CPP Liveness自己用的TaggedPointer。

64位指针对齐到8字节,每个合法对象地址的最低3位永远是0:

0x7fff12340000  ← 末位 0
0x7fff12340008 ← 末位 0
0x7fff12340010 ← 末位 0

IL2CPP直接借最低位做“已访问”标记:

#define MARK_OBJ(obj)   (obj)->klass = ((obj)->klass | 1)
#define IS_MARKED(obj) ((obj)->klass & 1)
#define GET_CLASS(obj) ((obj)->klass & ~1)

零内存开销,完全不需要额外的哈希表记录“哪些对象访问过”。

借Attrs的位3也是同样的思路:这个位本来就闲着,使用前后保持向下兼容,所有读写Attrs的地方都按位掩码取自己关心的位。

未来要注意的是IL2CPP升级时,如果官方启用了位3表示别的语义,就要换一位。维护成本是每次跨版本升引擎时检查一下il2cpp-tabledefs.h的位定义有没有冲突。

3.4 第三道坎:GC描述符的暗坎

按上面的方案改完,直接挂在Static字段上的 [SkipLiveness] 是生效的—— 这正是3.2方案能解决的核心场景。

写法:静态字段直接标记(生效)

public class ConfigManager
{
[SkipLiveness]
public static List _configCache; // 跳过这个静态字段的 Liveness 扫描


[SkipLiveness]
public static Dictionary _strTable; // 同上
}

Liveness跑到ConfigManager时,FromStatics迭代它的所有静态字段,在FieldCanContainReferences里查field.attrs & FIELD_ATTRIBUTE_SKIP_LIVENESS,命中就直接Continue。这两个字段连同它们引用的整张对象图,全部不再扫描—— 这正是省下耗时的来源。

但在实战中,业务代码不会这么干净。更常见的写法是把多个字段封装到一个数据类里,然后Static字段持有这个类的实例。这时候问题就来了:把 [SkipLiveness] 标记到嵌套实例字段上,无效

写法:嵌套实例字段标记(失效)

public classTestData
{
[SkipLiveness]
public List