UE4 垃圾回收
UE4GC简介
UE4为我们搭建了一套UObject对象系统,并且加入了垃圾回收机制,使我们用C++进行游戏开发时更加方便,而且游戏本身也可以极大程度的避免了内存泄漏问题。
UE4采用了标记-清扫垃圾回收方式,是一种经典的垃圾回收方式。一次垃圾回收分为两个阶段。第一阶段从一个根集合出发,遍历所有可达对象,遍历完成后就能标记出可达对象和不可达对象了,这个阶段会在一帧内完成。第二阶段会渐进式的清理这些不可达对象,因为不可达的对象将永远不能被访问到,所以可以分帧清理它们,避免一下子清理很多UObject,比如map卸载时,发生明显的卡顿。
GC发生在游戏线程上,对UObject进行清理,支持多线程GC。
对GC可以设置若干参数,比如MaxObjectsInGame,规定了游戏中最大存在的UObject对象(对编辑器不生效),移动平台上默认设置了131072,当UObject数量超过这个阈值时,游戏会崩溃,其他详细参数可见UGarbageCollectionSettings,GarbageCollection.cpp,UnrealEngine.cpp中相关的属性。
下图为标记-清扫的工作原理:
GC何时进行:
UE4中GC可以分为主动引发和自动引发两种方式
主动引发
可以在执行一些操作时手动调用GC,比如卸载一个资源后,立即调用一次GC进行清理。
而且方式有多种,游戏中可以调用ForceGarbageCollection来让World下次tick时进行垃圾回收。也可以直接调用CollectGarbage进行垃圾回收,引擎中大部分情况都用这种方式主动引发。
自动引发
游戏中,大部分的垃圾回收操作都是由UE4自动引发的,普通情况下不需要手动调用GC,这也是理想的GC使用方式。
当World进行tick时,会调用UEngine::ConditionalCollectGarbage()函数,函数中进行了一些判断,当满足GC条件时,才会执行GC。下面分析一下ConditionalCollectGarbage的执行逻辑。
UE4GC流程
入口为UObjectGlobals.h中定义的CollectGarbage()函数,如下:
void CollectGarbage(EObjectFlags KeepFlags, bool bPerformFullPurge)
// No other thread may be performing UObject operations while we're running
AcquireGCLock();
// Perform actual garbage collection
CollectGarbageInternal(KeepFlags, bPerformFullPurge);
// Other threads are free to use UObjects
ReleaseGCLock();
过程包括3个部分,获取GC锁,执行CollectGarbageInternal,释放GC锁。
获取GC锁
因为GC是多线程的,因此要设置GC锁,防止其他线程做UObject相关操作,会与GC冲突,这主要用于保护异步加载过程。
一个作用为防止一个对象被加载后,存储的变量还没来得及添加引用,就被当作不可达垃圾回收掉了。如下代码就是一个例子,FGCScopeGuard起到了阻止任何GC操作的作用。
FGCSyncObject
GC锁是一个广义的概念,其实是FGCSyncObject这个单例类,其内部封装了多个用于锁和同步的变量,可以用于在GC运行时阻塞其他non-game线程,也可以在non-game线程执行关键操作时阻塞GC线程。当然,也并非所有情况都会阻塞,当不能立即获取到GC锁时各个线程也可以根据具体逻辑执行其他内容。
主要成员变量如下:
FThreadSafeCounter AsyncCounter:是一个线程安全计数器,当由线程执行关键Async操作时,会对这个值进行增减
FThreadSafeCounter GCCounter:用作GC锁,不为0表示线程已经获取了GC锁,正在执行GC
FThreadSafeCounter GCWantsToRunCounter:这个计数器表示线程意向进行GC,但尚未获取到GC锁,Async线程没有自动强制实现这个逻辑,需要我们手动实现对这个变量的支持
FCriticalSection Critical:线程执行GC相关操作的关键区的保护,防止其他人进入
FEvent* GCUnlockedEvent:通知non-game线程GC正在执行的event,可执行Wait(),Trigger(),类似给线程的signal
对GC锁有了基本认识后,接下介绍一下获取GC锁的过程:
执行CollectGarbageInternal
执行CollectGarbageInternal,进行垃圾回收,进行标记与清扫
该函数接受2个参数:KeepFlags,bPerformFullPurge。KeepFlags表示有这些标记的object无论是否被引用到,都会被保留。bPerformFullPurge表示是否在标记后进行全purge,而不是分帧递增清除。
执行流程如下:
其中可以看到几个注意点:
- 这个流程一定会执行扫描对象可达性操作,黄色方框为具体的流程,稍后会做分析。清理操作视情况而执行,如有需要才会进行对象清理,而且一定是完全清理,否则就在World tick里面做增量清理。
- UE4会运行在多线程环境,GC又会对所有UObject进行操作,所以一定要注意锁的使用。
- GC本身可以多线程进行,加快速度
标记流程
使用FRealtimeGC的PerformReachabilityAnalysis方法进行uobject可达性分析。FRealTimeGC继承自FGarbageCollectionTracer,可以多线程、实时的分析对象引用关系。
关键代码如下:
// Make sure GC referencer object is checked for references to other objects even if it resides in permanent object pool
if (FPlatformProperties::RequiresCookedData() && FGCObject::GGCObjectReferencer && GUObjectArray.IsDisregardForGC(FGCObject::GGCObjectReferencer))
ObjectsToSerialize.Add(FGCObject::GGCObjectReferencer);
const double StartTime = FPlatformTime::Seconds();
MarkObjectsAsUnreachable(ObjectsToSerialize, KeepFlags, bForceSingleThreaded);
UE_LOG(LogGarbage, Verbose, TEXT("%f ms for Mark Phase (%d Objects To Serialize"), (FPlatformTime::Seconds() - StartTime) * 1000, ObjectsToSerialize.Num());
const double StartTime = FPlatformTime::Seconds();
PerformReachabilityAnalysisOnObjects(ArrayStruct, bForceSingleThreaded);
UE_LOG(LogGarbage, Verbose, TEXT("%f ms for Reachability Analysis"), (FPlatformTime::Seconds() - StartTime) * 1000);
}
这里用到了一个FGCArrayStruct类型数据结构ArrayStruct,用于存储用于序列化uobject的array和weak reference列表。
第一步,我们可以向ObjectsToSerialize添加FGCObject::GGCObjectReferencer
后者是一个静态的UGCObjectReferencer,添加后可用于在非UObject对象上调用AddReferencedObjects方法。
第二步,调用MarkObjectsAsUnreachable方法,把所有不带KeepFlags和EInternalObjectFlags::GarbageCollectionKeepFlags标记的对象标记为不可达
首先,这里涉及到GUObjectArray这个变量,这是一个全局的Uobject allocator,其中的ObjObjects数组保存了所有的UObject(通过FUObjectItem进行封装),UObjectBase::InternalIndex属性就是对象对应的FUObjectItem在数组中的下标,因此可以方便的根据下标找到UObject或者通过UObject找到对应下标。
GUObjectArray中前部存储了一些不纳入GC的object,因此扫描的object列表中会去掉前面这些object,只考虑后面的,得到MaxNumberOfObjects。具体哪些对象不被GC考虑,可以查看FUObjectArray的实现。
接下来就需要对这些uobject进行不可达标记,这里使用了多线程版本的For循环。多线程执行的原理并不复杂,首先可以获取当前可用的工作线程,然后把待标记的object平均分配给这些线程进行遍历,多线程底层使用了UE的GraphTask框架。在对一个uobject进行标记时,正常情况下都读对应的FUObjectItem中属性,特殊情况才读uobject,因为FUObjectItem是一个结构体,而且在GUObjectArray中紧密排列,所以在顺序遍历下是缓存友好的。
值得一提的是,UE使用了簇(Cluster)来提高效率,具体如何提高会在下面介绍。如果一个object属于RootSet,则直接加入到ObjectsToSerializeList中,如果是ClusterRoot或在Cluster中,也加入到KeepClusterRefsList列表中。如果object的ClusterRootIndex<=0(不在cluster中或者为ClusterRoot),则先根据是否有KeepFlags,判断是否要标记为不可达,如果不要标记,则把object加到ObjectsToSerializeList中,且如果为ClusterRoot就加入到KeepClusterRefsList中,如果要标记,则加入到ClustersToDissolveList中,且对ObjectItem设置Unreachable标记。会对Cluster做一些额外的处理,细节可看代码。
第三步,调用PerformReachabilityAnalysisOnObjects来判断uobject可达性
这里会用到FGCReferenceProcessor,TFastReferenceCollector,FGCCollector这几个类,都同时支持单线程和多线程。
先介绍一下ReferenceToken概念
在UObject体系中,每个类有一个UClass实例用于描述该类的反射信息,使用UProperty可描述每个类的成员变量,但在GC中如果直接遍历UProperty来扫描对象引用关系,效率会比较低(因为存在许多非Object引用型Property),所以UE创建了ReferenceToken,它是一组toke流,描述类中对象的引用情况。下图中列举了引用的类型:
/**
* Enum of different supported reference type tokens.
enum EGCReferenceType
GCRT_None = 0,
GCRT_Object,
GCRT_PersistentObject,
GCRT_ArrayObject,
GCRT_ArrayStruct,
GCRT_FixedArray,
GCRT_AddStructReferencedObjects,
GCRT_AddReferencedObjects,
GCRT_AddTMapReferencedObjects,
GCRT_AddTSetReferencedObjects,
GCRT_EndOfPointer,
GCRT_EndOfStream,
};
FGCReferenceTokenStream
这个类用于创建tokenstream和从tokenstream中解析出object引用,可以算是GC的一个核心理念了。ReferenceToken在其中保存为TArray<uint32>的形式,为什么是这种形式呢,下面就分析一下ReferenceToken的工作原理:
FGCReferenceInfo这个类描述了一个引用所需的信息,有一个union成员变量:
/** Mapping to exactly one uint32 */
union
/** Mapping to exactly one uint32 */
struct
/** Return depth, e.g. 1 for last entry in an array, 2 for last entry in an array of structs of arrays, ... */
uint32 ReturnCount : 8;
/** Type of reference */
uint32 Type : 4;
/** Offset into struct/ object */
uint32 Offset : 20;
/** uint32 value of reference info, used for easy conversion to/ from uint32 for token array */
uint32 Value;
};
Type:引用的类型,就是EGCRefenceType
Offset:这个引用对应的属性在类中的地址偏移
ReturnCount:返回的嵌套深度
UE巧妙的把这3个信息编码成了一个uint32,因此FGCReferenceTokenStream可以通过TArray<uint32>形式存储tokens。
当我们处理TokenStream时,可以先从中解析出一个个referencetoken,然后通过Offset直接获取属性,不仅处理起来更简单,更能有效利用缓存,加快速度。
TokenStream还有一种特殊的用法,就是用两个连续的token来存储一个指针(64位),比如运行时可以通过执行AddReferencedObjects来动态添加引用的对象,而这个函数的指针就储存在TokenStream中。
UClass::AssembleReferenceTokenStream(bool bForce)方法
可以实时创建tokenstream,只需执行一次,就能把结果保存下来,并在ClassFlags中通过CLASS_TokenStreamAssembled进行体现,避免重复计算。如果之前已经创建过TokenStream,就替换调旧的。
具体流程为:
- 遍历自身的UProperty(不包括父类的),依次调用UProperty的EmitReferenceInfo方法。这是一个虚函数,不同的UProperty会实现它,主要会把自己在Class中的内存偏移,ReferenceType信息发送给UClass,UClass再通过EmitObjectReference把这个引用信息编码成token,加入到ReferenceTokenStream中。不同的UProperty处理方式有很大区别,普通的UObjectProperty比较好处理,UArrayProperty和UMapProperty就比较复杂,因为它们内部的数据类型也需要生成TokenStream,如果碰到struct,还会涉及到递归。
- 如果这个类有父类,则递归调用父类的AssembleReferenceTokenStream方法,生成父类的ReferenceTokenStream,并把父类的stream添加到自己的stream之前。这个步骤会一直到UObjectBase这个类为止,UObjectBase的处理方式比较特殊,只会把ClassPrivate和OuterPrivate添加到stream中。
- 如果自身的AddReferencedObjects()函数不是指向Uobject::AddReferencedObjects,则向TokenStream中加入或更新这个函数指针对应的token,在执行可达性分析时即可调用到这个函数了。
- TokenStream添加完毕,把"EndOfStream"token添加到TokenStream,并对tokens array进行shrink,去掉空闲的array slack,因为toneks数组长度在接下来应该是固定的。
- ClassFlags中把CLASS_TokenStreamAssembled设为true。
TFastReferenceCollector
CollectReferences方法用于可达性分析,如果时单线程,就直接调用ProcessObjectArray方法,遍历uobject的token stream来寻找引用关系。如果是多线程,就会把uobject列表分割给多个线程处理,每个线程同样会调用到ProcessObjectArray。
ProcessObjectArray方法会遍历ObjectsToSerialize中的UObject,找到引用关系,判断可达性。注意,过程中ObjectsToSerialize会不断增长,直到全部遍历完。内部使用了递归的方法,但用栈来模拟。
- 如果是单线程且开启了自动生成tokenstream,则当object对应的UClass还没有tokenstream时,就实时调用UClass的AssembleReferneceTokenStreams创建tokenstream
- 获取当前uobject的TokenStream,解析出FGCReferenceInfo,来找到正被引用的UObject。
token的ReferenceInfo会是不同的类型,需要分多种情况处理。像GCRT_Object和GCRT_ArrayObject都比较好处理,只要把其中的uobject对象添加到ObjectsToSerialize中就行了。
GCRT_ArrayStruct就比较麻烦,需要递归处理。这里说的"struct"并不单指C++中的struct结构体,一些不属于UObject体系的class也算,比如UEdGraphPin。处理GCRT_ArrayStruct时,需要先把递归的栈递增,然后逐个处理Array中的"Struct"。
GCRT_AddStructReferencedObjects表示struct或不继承自FGCObject的class也可以对UOBject添加引用关系,UStructProperty::EmitReferenceInfo中代码也确实显示structproperty可以添加引用。但看代码和注释,觉得UE4应该以后会把这些特殊的struct和class都继承FGCObject,使用AddReferencedObjects函数来添加引用。
GCRT_AddReferencedObjects就表示需要调用这个对象的AddReferencedObjects函数来添加引用。让我们回想一下FGCObject,这个类不继承UObject,但也能通过AddReferencedObjects函数来对UObject添加引用,同时这个函数又只能由UClass来添加到TokenStream中,那FGCObject是怎么工作的?其实UE中有一个专门的UObject实例来管理FGCObject,就是UGCObjectReferencer,看一下这个类的AddReferencedObjects函数:
void UGCObjectReferencer::AddReferencedObjects(UObject* InThis, FReferenceCollector& Collector)
UGCObjectReferencer* This = CastChecked<UGCObjectReferencer>(InThis);
// Note we're not locking ReferencedObjectsCritical here because we guard
// against adding new references during GC in AddObject and RemoveObject.
// Let each registered object handle its AddReferencedObjects call
for (FGCObject* Object : This->ReferencedObjects)