网络同步中, Package异步加载

背景

UE中加载机制是, 加载会有一个队列, 一个同步加载会flush掉前面所有的异步加载, 进而造成卡顿. 例如: 当前存在异步加载A和B, 此时我调用同步加载C, 会立即将A和B flush掉, 然后执行C.

UE NetAsyncLoad大致流程:

在TickDispatch(收包)阶段, 针对所有ExportNetGUID(这里就指其所对应的UObject, 下同)进行异步加载, 并将其填入PendingGuidResolves中. 在接收Bunch中, 如果需要等待GUID加载或者QueuedBunch还没处理完, 就将该Bunch添加到QueueBunch中.

在TickFlush(发包)阶段, 解析GUID, 如果PendingGuidResolves为空, 并且其他条件也满足了, 就会处理QueueBunches.

可以看到上述流程中, 如果PendingGUID都已经被解析了, 那么走的流程和正规流程没什么区别. 如果存在没有被解析的PendingGUID, 则会将Bunch放入QueuedBunches中.

关键变量

IsExportingNetGUIDBunch

1
2
// 在对应的逻辑处理期间, 是否需要将被处理的Bunch视作ExportGuidBunch.
FNetGUIDCache.IsExportingNetGUIDBunch

当DS导出ExportGuid时候, 需要将该Bunch视作ExportBunch.

当客户端收到NetGUIDBunch时候, 也需要将其视作ExportGUIDBunch.

bHasMustBeMappedGUIDs

1
2
// 该bunch是否存在在该Bunch处理之前必须被映射的GUID. 即处理该bunch之前,其内部使用的某些GUID是否必须被映射. 后面会紧跟着需要被映射的GUID列表
uint8 bHasMustBeMappedGUIDs:1; // This bunch has guids that must be mapped before we can process this bunch

DS端处理MustBeMappedGUIDs

如果开启NetAsyncLoading, DS会将该Bunch(非ExportGuidBunch)中用到的GUID放入Bunch头部, 记作MustBeMappedGuids.

填充UPackageMapClient.MustBeMappedGuidsInLastBunch:

将MustBeGUIDs序列化到Bunch中:

客户端处理MustBeMappedGUID

当客户端收到的Bunch中含有MusMappedGUID时, 会将本地确实没有的但是还需要映射的GUID添加到UActorChannel.PendingGuidResolves中, 等待解析完成后处理该Bunch.

开启NetAsyncLoading

当开启NetAsyncLoading并且客户端收到新Bunch时, 会检测MustBeMappedGUID是否已经全部已经映射完成, 如果完成则处理Bunch, 否则放入QueueBunch中等待处理.

命令:

1
2
3
4
5
6
7
8
static TAutoConsoleVariable<int32> CVarAllowAsyncLoading(
TEXT("net.AllowAsyncLoading"),
0,
TEXT("Allow async loading of unloaded assets referenced in packets."
" If false the client will hitch and immediately load the asset,"
" if true the packet will be delayed while the asset is async loaded."
" net.DelayUnmappedRPCs can be enabled to delay RPCs relying on async loading assets.")
);

在ini中配置:

在程序中调用:

DS处理

在序列化Bunch时, 如果存在还没被客户端ACK的GUID, 则将其放入ExportGUIDBunch中. 当开启NetAsyncLoading后, 在序列化Bunch时, 还会将其放入MustBeMappedGUIDs中, 最后再将MustBeMappedGUIDs放在Bunch的头部.

当开启NetAsyncLoading后, 在DS端, 如果该Bunch并不是ExportGUIDBunch, 并且GUID需要客户端加载, 则将之放入MustBeMappedGuidsInLastBunch中.

哪些GUID又需要客户端加载呢?

  1. 静态GUID
  2. 非地图GUID
  3. 非NoLoad的GUID(不加载只find的GUID)

将MustMapGUID序列化到Bunch中:

最后, 将Bunch发送给客户端.

客户端处理

UE NetAsyncLoad大致流程:

在TickDispatch(收包)阶段, 针对所有ExportNetGUID(这里就指其所对应的UObject, 下同)进行异步加载, 并将其填入PendingGuidResolves中. 在接收Bunch中, 如果需要等待GUID加载或者QueuedBunch还没处理完, 就将该Bunch添加到QueueBunch中.

在TickFlush(发包)阶段, 解析GUID, 如果PendingGuidResolves为空, 并且其他条件也满足了, 就会处理QueueBunches.

异步加载资源

在处理ExportGUIDBunch时候, 会调用UPackageMapClient::InternalLoadObject, 在里面会触发FNetGUIDCache.GetObjectFromNetGUID, 进而调用FNetGUIDCache.StartAsyncLoadingPackage, 即触发异步加载.

反序列化ExportGUIDBunch里面所有的GUID:

如果开启NetAsyncLoading, 会触发异步加载.

注意:UPackage加载完成后并不设置FNetGuidCacheObject.Object, 而是在UNetConnection.Tick中每帧重新查找(StaticFindObject)绑定.

将GUID添加到PendingGuidResolves中

DS收到的Bunch不允许为bHasMustBeMappedGUIDs类型Bunch

DS不允许有bHasMustBeMappedGUIDs, 否则直接报错, 返回. 因为DS端是Guid的配置者, 它不可能收到新的GUID, 所有GUID由它分配.

客户端将GUID添加到PendingGuidResolves

当客户端收到MusMappedGUID时, 会将本地确实没有加载的但是还需要映射的GUID添加到UActorChannel.PendingGuidResolves中.

将Bunch存入QueueBunch中

满足以下条件, Bunch会放入QueueBunch中,等待处理.

  1. PendingGuidResolves已经有了待解析的GUID.
  2. QueuedBunches中还有待解析Bunch.
  3. 想要ShutDown的Actor, 但是在处理一些事情之前还想处理bunch.
  4. Driver范围内需要针对该Actor进行QueueBunch

处理QueueBunch

清理PendingGuidResolves中元素

客户端在UNetConnection.Tick时, 每次查询GUID对应的Obj是否存在, 如果存在进行绑定, 并删除PendingGuidResolves中的元素.

堆栈

通过StaticFindObject查找GUID对应的Obj, 然后进行绑定:

处理QueueBunch

当条件都满足时, 就可以处理Bunch了.

注意

通过上文可以发现, UE并不是针对每个Bunch单独记录其MustBeMappedGUID, 而是统一记录, 直至清空. 这样做虽然简单, 但是会产生一个问题, 就是如果一直有需要MustBeMappedGUID的Bunch, 那么客户端就会阻塞Bunch的处理, 这个可能引发连锁反应, 就是虽然不卡, 但是游戏仿佛停止了. 目前, UE是针对ActorChannel做Bunch的阻塞, 即不同AcotrChannel是相互独立的.

UActorChannel.QueuedBunches归属于ActorChannel. UActorChannel.ProcessQueuedBunches也是在ChannelTick中执行的. 即Bunch已经存储在对应的Channel中了.

进一步优化方法: 针对Bunch, 单独记录其MustBeMappedGUID, load完一个, 处理一个, 这样可以保证游戏正常运行.

构建复现环境

首先, 编辑器下无法复现. 原因: DS和客户端同处一份环境中, 资源加载是共享的, 所以无法复现DS加载但是客户端还没加载的情况.

了解了原因, 可以专门启用命令行DS, 然后客户端一定要保证没加载过该资源, 例如第一次启动编辑器, 并且默认地图的相关引用没有引用到该资源.

在一个空场景使用Standalone方式启动客户端连接DS.

连接DS

DS端专门创建了一个Actor, 并开启Replication.

1
2
3
4
5
6
7
8
9
if (LocalFrameIndex==10)
{
FVector Loc = FVector::ZeroVector;
FRotator Rot = FRotator::ZeroRotator;
FActorSpawnParameters Par;
Par.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
UClass *pClass = LoadObject<UClass>(this, TEXT("Blueprint'/Game/GamePlay/BP_TestDormancy.BP_TestDormancy_C'"));
TestActor = GetWorld()->SpawnActor<AActor>(pClass, Loc, Rot, Par);
}

客户端下断点, 并连接DS.

总结

  1. NetAsyncLoading仅仅在客户端网络层触发异步加载. 在DS端是不需要的, 因为DS不会存在网络层加载UObject的情况, 一般都是主动Load后Spawn/NewObject.
  2. 需要异步加载的Guid对应的资源全部都是静态的, 并且是客户端可以加载的(非Level, 非NoLoad).
  3. 开启NetAsyncLoading, DS会将MustedMappedGUID放入Bunch(非ExportGUIDBunch)头部, 用bHasMustBeMappedGUIDs表示.
  4. 开启NetAsyncLoading, 客户端会异步加载GUID对应的Package(当前还没加载), 在此期间收到的Bunch都放入BunchQueue中, 等待GUID对应的Package加载完成后处理.
  5. 必须等所有MustBeMappedGUID都处理完成后才会处理BunchQueue.(ActorChannel级别的阻塞, 不同ActorChannel是相互独立的.)
  6. UPackage加载完成后并不设定FNetGuidCacheObject.Object, 而是在UNetConnection.Tick中每帧重新查找(StaticFindObject)绑定.