首发于UE4随笔
UE4 垃圾回收

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,而不是分帧递增清除。

执行流程如下:

其中可以看到几个注意点:

  1. 这个流程一定会执行扫描对象可达性操作,黄色方框为具体的流程,稍后会做分析。清理操作视情况而执行,如有需要才会进行对象清理,而且一定是完全清理,否则就在World tick里面做增量清理。
  2. UE4会运行在多线程环境,GC又会对所有UObject进行操作,所以一定要注意锁的使用。
  3. 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,就替换调旧的。

具体流程为:

  1. 遍历自身的UProperty(不包括父类的),依次调用UProperty的EmitReferenceInfo方法。这是一个虚函数,不同的UProperty会实现它,主要会把自己在Class中的内存偏移,ReferenceType信息发送给UClass,UClass再通过EmitObjectReference把这个引用信息编码成token,加入到ReferenceTokenStream中。不同的UProperty处理方式有很大区别,普通的UObjectProperty比较好处理,UArrayProperty和UMapProperty就比较复杂,因为它们内部的数据类型也需要生成TokenStream,如果碰到struct,还会涉及到递归。
  2. 如果这个类有父类,则递归调用父类的AssembleReferenceTokenStream方法,生成父类的ReferenceTokenStream,并把父类的stream添加到自己的stream之前。这个步骤会一直到UObjectBase这个类为止,UObjectBase的处理方式比较特殊,只会把ClassPrivate和OuterPrivate添加到stream中。
  3. 如果自身的AddReferencedObjects()函数不是指向Uobject::AddReferencedObjects,则向TokenStream中加入或更新这个函数指针对应的token,在执行可达性分析时即可调用到这个函数了。
  4. TokenStream添加完毕,把"EndOfStream"token添加到TokenStream,并对tokens array进行shrink,去掉空闲的array slack,因为toneks数组长度在接下来应该是固定的。
  5. ClassFlags中把CLASS_TokenStreamAssembled设为true。


TFastReferenceCollector

CollectReferences方法用于可达性分析,如果时单线程,就直接调用ProcessObjectArray方法,遍历uobject的token stream来寻找引用关系。如果是多线程,就会把uobject列表分割给多个线程处理,每个线程同样会调用到ProcessObjectArray。

ProcessObjectArray方法会遍历ObjectsToSerialize中的UObject,找到引用关系,判断可达性。注意,过程中ObjectsToSerialize会不断增长,直到全部遍历完。内部使用了递归的方法,但用栈来模拟。

  1. 如果是单线程且开启了自动生成tokenstream,则当object对应的UClass还没有tokenstream时,就实时调用UClass的AssembleReferneceTokenStreams创建tokenstream
  2. 获取当前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)
	{
		check(Object);
		Object->AddReferencedObjects(Collector);
	}
	Super::AddReferencedObjects( This, Collector );
}

引用标记阶段搜集到这个类的实例时,它会逐个调用FGCObject上的AddReferencedObjects方法,搜集UObject引用,从而把FGCObject纳入到GC体系中。

3. 得到被引用的UObject后,一般会对其添加引用,并加入到ObjectsToSerialize数组中。

如果一个UObject已经被标记为isPendingKill了,那么即使它被引用到,也会忽略。

由于标记可以多线程进行,因此有可能两个线程同时对一个对象标记为可达,并加入到ObjectsToSerialize数组,继续进行引用检查,这显然是浪费的。因此对一个对象进行标记时,不仅要检查这个对象当前是否为Unreachable,清理它的Unreachable标记也要有一个原子的“比较再替换”操作,防止两个线程碰巧同时设置。

如果这个UObject在Cluster中,则把它标记为ReachableInCluster,同时如果需要也把它的ClusterOwner标记为可达,并加入到ObjectsToSerialize中做后续处理。

4. 对ObjectsToSerialize数组的扫描会一轮一轮进行,一轮扫描过程中扫描到的新的UObject会暂时存放在NewObjectsToSerialize数组中,当对ObjectsToSerialize一轮扫描完时,如果NewObjectsToSerialize中元素数量超过MinDesiredObjectsPerSubTask这一阈值,则新开多个线程处理,如果不到阈值,则在当前线程中继续新的一轮处理。

得到待清理Uobject列表

首先介绍一下Cluster概念:

对于复合性的逻辑物体,其内部的Object随父物体的状态进行管理,可以用Cluster来进行GC管理。Cluster是一组UObject,在GC流程中被视为一个单一的单位,能加速GC。粒子系统中,一组粒子对象就被标记为一个Cluster。Actor也可以通过设置“can be in Cluster”熟悉把自己加到一个Cluster中。

在程序中的表示为FUObjectCluster这个类。其中Objects属性为Cluster中的所有Objects,ReferencedClusters为这个cluster引用的其他Cluster。

当一个Cluster引用的其他Cluster根对象被标记为PendingKill时,就要把这个Cluster中的所有Object都加入到ObjectsToSerialize数组中,来处理其他的pendingkill引用。同时,这个Cluster将会被标记为需要分解,因为cluster间的引用关系已经得不到保证了。

在分析完可达对象后,需要对标记为分解的Cluster进行分解操作。

之后,需要再扫描一遍所有的UObject,搜集所有不可达对象。这个操作也可以多线程并行处理。对于不可达对象,如果不在Cluster中,就把它直接加入到GUnreachableObjects数组中。如果是ClusterRoot,则需要对它所包含的所有Objects进行分析,如果Object没有来自Cluster外部的引用,则也为不可达,需要加入到GUnreachableObjects数组中。至此,GUnreachableObjects数组中的UObject就是这次扫描得到的待清理对象了。


释放GC锁

去掉GC锁,允许其他线程使用UObject,释放的流程比获取的流程简单很多

  1. 调用GCUnlockedEvent->Trigger(),唤醒正在等待这个event的线程
  2. GCCounter递减,表示退出GC状态


清理流程

清理和引用标记可以分开进行,通常清理是分帧递增执行的。

调用IncrementalPurgeGarbage( bool bUseTimeLimit, float TimeLimit )可进行垃圾清理,接受两个参数。

bUseTimeLimit:清理是否有时间限制

TimeLimit:方法一次执行的时间限制

通过这两个参数可以选择性的进行递增清理或者全量清理。


首先,会调用UnhashUnreachableObjects方法,对所有的不可达且未设置RF_BeginDestroyed的UObject调用BeginDestroy方法,通知这个UObject它将要被销毁,可以让UObject进行一些异步的清理操作,我们可以覆写BeginDestroy方法,执行自定义的操作。

之后,我们就可以获取GC锁了。

遍历要清理的对象,调用这些对象的FinishDestroy方法。但是这是对象有可能还没执行完BeginDestroy,因为有一些异步操作,比如一些图形资源在等待渲染线程使用完毕,所以对这些对象不能立即调用FinishDestroy,而要把它们先储存到GGCObjectsPendingDestruction数组中,之后再处理。遍历过程中会检查已过的时间,如果时间限制到了,就会中途停止,并记下当前的进度,之后会退出清理流程。

如果我们把所有要清理的对象都遍历完了,即对它们都尝试调用了FinishDestroy方法,那么会再遍历一遍GGCObjectsPendingDestruction数组,再尝试执行一下FinishDestroy。当对GGCObjectsPendingDestruction的这一次遍历完成后,就要分是否使用TimeLimit分情况看了。如果使用了TimeLimit,就直接执行下面逻辑,下次再处理GGCObjectsPendingDestruction中的对象。如果不使用,就会阻塞在这个地方,强行等待哪些UObject执行完BeginDestroy,主要还是等待渲染线程。当GGCObjectsPendingDestruction中的所有对象都执行了FinishDestroy后,才会进行下一步的清理操作。

接下来会执行真正的清理过程。主要会遍历待清理对象,执行UObject的析构函数,并释放内存空间。这个步骤也会检查释放超过了TimeLimit,超过的化会在下一帧继续清理。


指向UObject的指针如何更新为NULL

这是一个引申出来的问题。我们可以显示调用Uobject的Destroy()函数,这样下次垃圾回收时这个UObject就会被回收掉,但是当前还指向这个UObject的UProperty指针,或者容器中的指针,怎么办呢,会不会造成“悬空指针”问题呢?答案是不会的,使用过UE4就知道,判定一个UProperty指针是否有效,只要判定一下是否为空即可,为我们进行C++开发提供了很多便利。UE4在一个对象销毁时,会自动把指向这个对象的UProperty指针更新为Null,不是UProperty的指针不会被更新,依然会有悬空指针问题。UE4的官方文档中有比较详细的描述,docs.unrealengine.com/e (Automatic Updating of References)。

指针自动更新实现原理

在可达性分析阶段,我们会解析每个对象的tokenstream,找到当前对象引用的其他对象,如果其他对象已经被标记为PendingKill了,就会把指向它的指针置为NULL。所以指针更新流程是先把所有指向将要销毁对象的指针置为NULL,再销毁这个对象,这样就肯定不会出现悬空指针问题了。

编辑于 2019-07-15

文章被以下专栏收录