技术博客

加载卡顿定位:AsyncLoading 与 FlushAsyncLoading

第 98 / 100 篇 · 2025-08-21 · ue5 · 性能优化加载流送

有一种卡顿的特征极其鲜明:平时帧率平稳,走到特定位置、放出特定技能、打开特定界面时卡一下,短的几十毫秒,长的半秒。十有八九是主线程同步加载。这一篇讲它的机制、排查证据链和治理手段,基于 UE5.3 的 AsyncLoading2(EDL,事件驱动加载器)。

机制:为什么"加载"会卡主线程

异步加载的 IO 和解序列化本来就在专用线程(AsyncLoadingThread)上跑,主线程理论上只接收完成通知。但有一批 API 是同步语义的:LoadObject、LoadPackage、SyncLoadAsset、FStreamableManager::LoadSynchronous,以及所有内部调用它们的封装(CreateWidget 同步创建带硬引用的 UI、SpawnActor 引用未加载资产、关卡流送的阻塞加载)。

同步语义的实现只有一个:FlushAsyncLoading()——主线程停住,把"我现在就要的对象"相关的所有未完成加载现场消费完才返回。于是卡顿时长可以写成:

$$T_{stall} \approx \frac{W_{remaining}}{v_{async}}$$

$W_{remaining}$ 是触发同步点时还没做完的加载工作量(剩余包的反序列化 + PostLoad + 依赖链),$v_{async}$ 是异步管线的吞吐。注意分子是剩余量不是总量:异步管线早就开跑了,主线程只是最后那一下等收尾——所以同样的资产,预热越充分,同步点等得越短;完全没预热的冷路径,等的就是全部。

还有一个隐蔽来源:异步加载完成后的对象需要在主线程做 PostLoad 和引用修补,这部分工作有每帧时间片限制(r.AsyncLoadingTimeLimit,默认几毫秒)。时间片小、这一帧又涌进来大量完成对象时,"消化"会拖好几帧——表现不是单次大卡,而是持续数帧的小掉帧。调大这个时间片能压平,代价是单帧负载变重,是平滑和时延的取舍。

证据链:怎么确认是同步加载

Insights 里的签名很直白,按确认顺序看:

  1. GameThread 轨道上出现 FlushAsyncLoading / LoadPackage 长条:这就是主线程在等。点开调用栈能看到是哪个同步 API 发起的;
  2. 卡顿帧的 AsyncLoad 轨道同时 busy:说明异步管线本来就在干活,佐证"等了剩余量";
  3. 抓不到 Insights 时用日志侧面验证:-fileopenlog 记录每次文件打开,卡顿时刻的时间戳附近如果有一串包加载记录,方向就定了;
  4. stat streaming/stat asyncload 看加载队列深度:常态化有积压,说明预载策略整体缺位。

同步加载的触发点在代码里也有规律:搜 LoadSynchronous、LoadObject、CreateWidget、LoadStreamLevel(bShouldBlockOnLoad=true)这些调用,凡是跑在游戏流程里的(而不是一次性初始化里的),都是嫌疑犯。

同步点模型:Flush 等待 = 剩余工作量 / 异步吞吐 AsyncLoadingThread:IO → 反序列化 →(完成对象排队等主线程 PostLoad) GameThread:FlushAsyncLoading 主线程停住,直到所需包全部就绪 T_stall = W_剩余 / v_async 预热充分 → 剩余量小 预热路径 RequestAsyncLoad 提前发起 同步触发点 LoadSynchronous / CreateWidget 时间片消化 r.AsyncLoadingTimeLimit Insights 签名:GameThread 长 FlushAsyncLoading + 同时刻 AsyncLoad 轨道 busy = 实锤

治理:把同步点改成预热

确认了元凶,治理思路只有一条:让同步调用发生时,它要的东西已经加载完。手段按侵入度排:

  • 引用软化 + 显式预载:把硬引用改成 TSoftObjectPtr / FSoftObjectPath,在流程上游(进战斗前、打开界面前一拍)用 FStreamableManager::RequestAsyncLoad 发起加载,回调或下一帧再使用。同步调用变成"必然命中已加载",Flush 等的就是零;
  • PrimaryAssetBundle 预载:GAS 技能、关卡配套资源这类成组资产,用 AssetManager 的 Bundle 机制在阶段切换时整批预载,比散落的手动调用好维护;
  • 关卡流送去阻塞:LoadStreamLevel 默认阻塞,改成异步后监听完成事件再切换 Gameplay 逻辑;World Partition 项目确认 cell 的加载策略不是 visibility 驱动下的阻塞路径;
  • 时间片调节:r.AsyncLoadingTimeLimit 和 PriorityAsyncLoadingExtraTime 只解决"消化抖动",不解决同步等待,别拿它当主药方。

改造时的一个判断标准:同步加载在一次性初始化路径(启动、进关卡)里可以接受,在高频运行路径(战斗、移动、UI 交互)里必须消灭。前者卡一次没人记得,后者每次出现都在消耗玩家耐心。

和 GC、物理的区分

三种"周期性卡顿"容易混:同步加载卡在触发点(和玩家行为强相关)、GC 尖刺有固定节奏(增量周期的全量 purge)、物理同步卡在查询密集处。Insights 里三者轨道不同(AsyncLoad / GarbageCollection / Physics),签名分得开。混在一起的场景(加载触发分配高峰诱发 GC)先治理加载,GC 压力往往跟着降。

加载卡顿是性能问题里回报最高的一类:根因集中、证据明确、改法套路化。把项目里所有运行路径上的同步加载扫一遍,通常一天的改造能消掉一大半的实测卡顿。