README.md 21 KB

GC

UE 采用 标记-清除 的方式进行垃圾回收

不使用 引用计数 有几个原因

  1. UE 源码中有特别多裸指针的调用,无法捕获这些指针引用计数
  2. 引用计数要用原子化的计数加减,耗时更大

创建一个新的 UObject

UObject 的基类是 UObjectBaseUtilityUObjectBaseUtility 的基类是 UObjectBase

UObjectBase 的构造函数中就做了一个事情 AddObject,最终会将该对象添加到 GUObjectArray

void UObjectBase::AddObject(FName InName, EInternalObjectFlags InSetInternalFlags, int32 InInternalIndex, int32 InSerialNumber)
{
	NamePrivate = InName;
	EInternalObjectFlags InternalFlagsToSet = InSetInternalFlags;
    
    // 设置 Tag ... do something ...

	GUObjectArray.AllocateUObjectIndex(this, InternalFlagsToSet, InInternalIndex, InSerialNumber);
	check(InName != NAME_None && InternalIndex >= 0);
	HashObject(this);
	check(IsValidLowLevel());
}

GUObjectArray 是一个全局唯一的 FUObjectArray,也就是说所有创建的 UObject 对象都会保存在这个数组里面

StaticAllocateObject 全局函数中

首先通过全局的 GUObjectAllocator 分配内存

int32 Alignment	= FMath::Max( 4, InClass->GetMinAlignment() );
Obj = (UObject *)GUObjectAllocator.AllocateUObject(TotalSize,Alignment,GIsInitialLoad);

然后通过 placement new 对内存区域进行构造函数

if (!bSubObject)
{
	FMemory::Memzero((void *)Obj, TotalSize);
	new ((void *)Obj) UObjectBase(const_cast<UClass*>(InClass), InFlags|RF_NeedInitialization, InternalSetFlags, InOuter, InName, OldIndex, OldSerialNumber);
}

在构造函数中,将该对象设置到 GUObjectArray

GC

明牌 UE 的 GC 机制是 标记-清除 算法

  1. 加锁,保持所有对象的引用关系不变
  2. 标记所有的 UObject不可达
  3. 遍历根对象列表 GUObjectArray,跟对象引用到的对象去除 不可达 标记
  4. 收集所有仍然标记为 不可达 的对象,全部删除
void CollectGarbage(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
	if (GIsInitialLoad)
	{
		UE_LOG(LogGarbage, Log, TEXT("Skipping CollectGarbage() call during initial load. It's not safe."));
		return;
	}
	AcquireGCLock();
	UE::GC::CollectGarbageInternal(KeepFlags, bPerformFullPurge);
}

FORCEINLINE void CollectGarbageInternal(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
	const double StartTime = FPlatformTime::Seconds();

	if (bPerformFullPurge)
	{
		CollectGarbageFull(KeepFlags);
	}
	else
	{
		CollectGarbageIncremental(KeepFlags);
	}

	GTimingInfo.LastGCDuration = FPlatformTime::Seconds() - StartTime;

	CSV_CUSTOM_STAT(GC, Count, 1, ECsvCustomStatOp::Accumulate);
}

AcquireGCLock

通过 AcquireGCLock 加锁,暂停其他线程对 UOBject 对象的引用关系产生影响

void AcquireGCLock()
{
	// 信息收集
	FGCCSyncObject::Get().GCLock();
	// 输出 GC 所用时间
}

本质就是在执行 FGCCSyncObjectGCLock 函数

首先将 GCWantsToRunCounter 值加 1,表示想要进行 GC 操作

IsGarbageCollectionWaiting 全局函数中返回 GCWantsToRunCounter 是否不为 0。该函数在 AsyncLoading 中被使用

随后就是让当前线程 Sleep,直到 AsyncCounter 值归零

AsyncCounter 值用于表示其他线程是否有阻塞GC的操作还在执行,当 AsyncCounter 归零表示可以执行 GC,此时顺便清空 GCWantsToRunCounter 的值

GCCounter 自增,表示当前正在执行 GC 操作,会在 GCUnlock() 函数中自减

使用 IsGCLocked() 可以通关判断 GCCounter 的值是否不为零来判断是否正在执行 GC 操作

void GCLock()
{
	// GCWantsToRunCounter 值加 1
	SetGCIsWaiting();

	bool bLocked = false;
	do
	{
		// Sleep 线程,直到 AsyncCounter 值归零
		FPlatformProcess::ConditionalSleep([&]()
		{
			return AsyncCounter.GetValue() == 0;
		});
		{
			FScopeLock CriticalLock(&Critical);
			if (AsyncCounter.GetValue() == 0)
			{
				GCUnlockedEvent->Reset();
				int32 GCCounterValue = GCCounter.Increment();
				FPlatformMisc::MemoryBarrier();
				ResetGCIsWaiting();		// 重置 GCWantsToRunCounter 值为 0
				bLocked = true;
			}
		}
	} while (!bLocked);
}

当上述流程执行完毕之后,就可以开始 GC 操作了

CollectGarbageInternal

FORCEINLINE void CollectGarbageInternal(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
	const double StartTime = FPlatformTime::Seconds();

	if (bPerformFullPurge)
	{
		CollectGarbageFull(KeepFlags);
	}
	else
	{
		CollectGarbageIncremental(KeepFlags);
	}

	GTimingInfo.LastGCDuration = FPlatformTime::Seconds() - StartTime;

	CSV_CUSTOM_STAT(GC, Count, 1, ECsvCustomStatOp::Accumulate);
}

根据 bPerformFullPurge 判断是否需要全量垃圾回收,全量回收执行 CollectGarbageFull,增量回收执行 CollectGarbageIncremental

FORCENOINLINE static void CollectGarbageFull(EObjectFlags KeepFlags)
{
	// 信息收集
	CollectGarbageImpl<true>(KeepFlags);
}

FORCENOINLINE static void CollectGarbageIncremental(EObjectFlags KeepFlags)
{
	// 信息收集
	CollectGarbageImpl<false>(KeepFlags);
}

template<bool bPerformFullPurge>
void CollectGarbageImpl(EObjectFlags KeepFlags)
{ // ...
}

通过 template 的方式,生成两个 CollectGarbageImpl 模板函数,减少判断 if-else

CollectGarbageImpl

可达性分析

标记所有可达对象,使用根集递归遍历

FRealtimeGC GC;
{
	// 信息收集
	GC.PerformReachabilityAnalysis(KeepFlags, Options);
	// 日志打印
}

// 二次分析 一般用于调试 检测非法引用(仅调试模式启用)
if (GC.Stats.bFoundGarbageRef && GGarbageReferenceTrackingEnabled > 0)
{
	// 信息收集
	GC.PerformReachabilityAnalysis(KeepFlags, Options);
	// 日志打印
}

FRealtimeGC::PerformReachabilityAnalysis 函数分为两个阶段,一个是标记,一个可达性分析

标记

在开始之前,先把 GGCObjectReferencer 内容添加到 InitialObjects 数组中

GGCObjectReferencer 存储的就是继承自 FGCObject 的非 UObject 对象

if (FPlatformProperties::RequiresCookedData() && GUObjectArray.IsDisregardForGC(FGCObject::GGCObjectReferencer))
{
	InitialObjects.Add(FGCObject::GGCObjectReferencer);
}
const EGCOptions OptionsForMarkPhase = Options & ~EGCOptions::WithPendingKill;
(this->*MarkObjectsFunctions[GetGCFunctionIndex(OptionsForMarkPhase)])(KeepFlags);

上述是标记代码,使用 MarkObjectsFunctions 函数指针,该函数指针初始在构造函数中

typedef void(FRealtimeGC::*MarkObjectsFn)(EObjectFlags);
MarkObjectsFn MarkObjectsFunctions[4];

FRealtimeGC()
{
	MarkObjectsFunctions[GetGCFunctionIndex(EGCOptions::None)] = &FRealtimeGC::MarkObjectsAsUnreachable<false>;
	MarkObjectsFunctions[GetGCFunctionIndex(EGCOptions::Parallel | EGCOptions::None)] = &FRealtimeGC::MarkObjectsAsUnreachable<true>;

	// do something else ...
}

所以,本质上是执行 FRealtimeGC::MarkObjectsAsUnreachable 函数,只是根据是否多线程来选择数组中的执行函数罢了

TArray<TArray<UObject*>, TInlineAllocator<32>> ObjectsToSerializeArrays;
ObjectsToSerializeArrays.SetNum(NumThreads);

ParallelFor( TEXT("GC.MarkUnreachable"),NumThreads,1,
	[&ObjectsToSerializeArrays, &ClustersToDissolveList, &KeepClusterRefsList,
		KeepFlags, NumberOfObjectsPerThread, NumThreads, MaxNumberOfObjects, bIsRerun = Stats.bFoundGarbageRef] (int32 ThreadIndex)
		{}, !bParallel ? EParallelForFlags::ForceSingleThread : EParallelForFlags::None);
  • NumThreads 是可以运行的线程数量
  • ObjectsToSerializeArrays 用于存储各个线程计算出来的 UObject*

通过 ParallelFor 启动多个线程,通过 ThreadIndex 来分块计算 GUObjectArray 中的 UObject*

int32 FirstObjectIndex = ThreadIndex * NumberOfObjectsPerThread + GUObjectArray.GetFirstGCIndex();
int32 NumObjects = (ThreadIndex < (NumThreads - 1)) ? NumberOfObjectsPerThread : (MaxNumberOfObjects - (NumThreads - 1) * NumberOfObjectsPerThread);
int32 LastObjectIndex = FMath::Min(GUObjectArray.GetObjectArrayNum() - 1, FirstObjectIndex + NumObjects - 1);

for (int32 ObjectIndex = FirstObjectIndex; ObjectIndex <= LastObjectIndex; ++ObjectIndex)
{}

那么对一个 UObject 进行进行标记的真正部分就在循环体中,通过 ObjectIndex 可以直接从 GUObjectArray 获取对应的 UObject

FUObjectItem* ObjectItem = &GUObjectArray.GetObjectItemArrayUnsafe()[ObjectIndex];
UObject* Object = (UObject*)ObjectItem->Object;

得到 UObject 第一步就是清除可达性标记

ObjectItem->ClearFlags(EInternalObjectFlags::ReachableInCluster);
  • 如果 UObject 是一个根对象,直接加入到 LocalObjectsToSerialize,如果它属于 或者 簇根 ,也加入到 KeepClusterRefsList
  • 如果 UObject 是一个簇对象,并且有 FastKeepFlags 也加入到 LocalObjectsToSerializeKeepClusterRefsList

FastKeepFlags = EInternalObjectFlags::GarbageCollectionKeepFlags

如果 UObject普通对象 或者 簇根

if (ObjectItem->HasAnyFlags(FastKeepFlags))
{
	bMarkAsUnreachable = false;
}
else if (!ObjectItem->IsPendingKill() && KeepFlags != RF_NoFlags && Object->HasAnyFlags(KeepFlags))
{
	bMarkAsUnreachable = false;
}

符合上述条件,就不会被标记为 不可达

如果是 ClusterRoot 也就是 簇根 对象,它是 PendingKill 或者 Garbage,那么这个簇应该被解散,会被加入到 ClustersToDissolveList 数组

手动销毁对象时,会给它添加 PendingKill 的标签

PRAGMA_DISABLE_DEPRECATION_WARNINGS
else if (ObjectItem->HasAnyFlags(EInternalObjectFlags::PendingKill | EInternalObjectFlags::Garbage) && ObjectItem->HasAnyFlags(EInternalObjectFlags::ClusterRoot))
PRAGMA_ENABLE_DEPRECATION_WARNINGS
{
	ClustersToDissolveList.Push(ObjectItem);
}

不可达的对象会被加入到打上不可达的标记,其他会加入到 LocalObjectsToSerializeKeepClusterRefsList 数组中

if (!bMarkAsUnreachable)
{
	LocalObjectsToSerialize.Add(Object);
	if (ObjectItem->HasAnyFlags(EInternalObjectFlags::ClusterRoot))
		KeepClusterRefsList.Push(ObjectItem);
}
else
{
	ObjectItem->SetFlags(EInternalObjectFlags::Unreachable);
}

前情提要,LocalObjectsToSerializeObjectsToSerializeArrays 通过 ThreadIndex 得到的

每个线程将自己计算的内容存储到对应的数组中,通过引用的方式存储到 ObjectsToSerializeArrays

TArray<UObject*>& LocalObjectsToSerialize = ObjectsToSerializeArrays[ThreadIndex];

由于 ObjectsToSerializeArrays 是一个 二维数组 TArray<TArray<UObject*>, TInlineAllocator<32>>,需要将其转换成 一维数组 然后进行后续计算,这个 一维数组 就是 InitialObjects

InitialObjects.Reserve(InitialObjects.Num() + NumTotal + UE::GC::ObjectLookahead);
for (TArray<UObject*>& Objects : ObjectsToSerializeArrays)
{
	InitialObjects.Append(Objects);
}

ObjectsToSerializeArrays.Empty();

将所有的 ClustersToDissolveList 中所有的对象,也就是不可达的 簇根 对象,将该簇中所有的对象标记为不可达

TArray<FUObjectItem*> ClustersToDissolve;
ClustersToDissolveList.PopAll(ClustersToDissolve);
for (FUObjectItem* ObjectItem : ClustersToDissolve)
{
	if (ObjectItem->HasAnyFlags(EInternalObjectFlags::ClusterRoot))
	{
		GUObjectClusters.DissolveClusterAndMarkObjectsAsUnreachable(ObjectItem);
		GUObjectClusters.SetClustersNeedDissolving();
	}
}

针对 KeepClusterRefsList 列表,该列表存储簇对象

遍历 KeepClusterRefsList 列表对象,如果对象可达,但是对象对应簇的根对象是不可达的,需要 复活 整个簇

顺便通过 MarkReferencedClustersAsReachable 通过遍历,将所有与该相关的都保留下来,存储到 InitialObjects

引用关系分析

根据 UClass文档 中对 UClass 的介绍,可以发现在编译代码之前就以及将对象信息收集完毕,包括属性的名称、类型、内存偏移等信息

当得到一个 UObject 的时候,通过其 UClass 可以计算各个属性的内存偏移,计算对象的引用关系也可以通过这种方式进行处理

FContextPoolScope Pool;
FWorkerContext* Context = Pool.AllocateFromPool();
Context->InitialNativeReferences = GetInitialReferences(Options);
Context->SetInitialObjectsUnpadded(InitialObjects);

// 分析 InitialObjects 数组内的对象,它能达到的对象,去掉不可达标签
PerformReachabilityAnalysisOnObjects(Context, Options);

是的, ReachabilityAnalysisFunctions 又是函数指针,根据不同的 EGCOptions 选择不同的模板函数

virtual void PerformReachabilityAnalysisOnObjects(FWorkerContext* Context, EGCOptions Options) override
{
	(this->*ReachabilityAnalysisFunctions[GetGCFunctionIndex(Options)])(*Context);
}

typedef void(FRealtimeGC::*ReachabilityAnalysisFn)(FWorkerContext&);
ReachabilityAnalysisFn ReachabilityAnalysisFunctions[8];

ReachabilityAnalysisFunctions[GetGCFunctionIndex(EGCOptions::None)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<EGCOptions::None | EGCOptions::None>;
ReachabilityAnalysisFunctions[GetGCFunctionIndex(EGCOptions::Parallel | EGCOptions::None)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<EGCOptions::Parallel | EGCOptions::None>;

ReachabilityAnalysisFunctions[GetGCFunctionIndex(EGCOptions::None | EGCOptions::WithPendingKill)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<EGCOptions::None | EGCOptions::WithPendingKill>;
ReachabilityAnalysisFunctions[GetGCFunctionIndex(EGCOptions::Parallel | EGCOptions::WithPendingKill)] = &FRealtimeGC::PerformReachabilityAnalysisOnObjectsInternal<EGCOptions::Parallel | EGCOptions::WithPendingKill>;

无论如何,最后调用的是 TFastReferenceCollector::ProcessObjectArray

ProcessObjectArray 中通过 ProcessObjects 处理对象,并且生成 Token 用于 GC 处理

ProcessObjects 函数中,遍历 CurrentObjects 所有的对象

  1. 获取当前类的 ClassOther
UObject* CurrentObject = It.GetCurrentObject();
UClass* Class = CurrentObject->GetClass();
UObject* Outer = CurrentObject->GetOuter();
  1. 通过 Class->AssembleReferenceTokenStream() 计算当前类的 Token 信息,用于简化对象占用内存,提高实时 GC 效率
if (!!(Options & EGCOptions::AutogenerateSchemas) && !Class->HasAnyClassFlags(CLASS_TokenStreamAssembled))
{			
	Class->AssembleReferenceTokenStream();
}
  1. 处理 ClassOther 的引用
FSchemaView Schema = Class->ReferenceSchema.Get();
// 设置当前 Context 中当前处理对象是 CurrentObject
Dispatcher.Context.ReferencingObject = CurrentObject;

Dispatcher.HandleImmutableReference(Class, EMemberlessId::Class, EOrigin::Other);
Dispatcher.HandleImmutableReference(Outer, EMemberlessId::Outer, EOrigin::Other);
  1. 通过 Schema 遍历 CurrentObject 的成员属性
Private::VisitMembers(Dispatcher, Schema, CurrentObject);

关于 UClass 生成的 Token 信息,通过断点可以窥探一斑,存储了 UPROPERTY 标记的属性名称、内存偏移、属性类型

VisitMembers 时会根据对象属性的类型 EMemberType 不同,调用不同的函数,启动之后会运行 Run 函数

清理对象

收集对象

CollectGarbageImpl 函数中,在 GC.PerformReachabilityAnalysis 标记 GC 之后,调用了 GatherUnreachableObjects 函数

函数内容最关键的有两个

GUnreachableObjects.Append(ThisThreadUnreachableObjects);
// ... do something 
GUnreachableObjects.Add(ClusterObjectItem);

GUnreachableObjects 是一个全局对象,存储着不可达对象的指针,每次 标记 阶段之后都会将标记为 不可达 的对象添加进去

删除对象

有一个名为 FAsyncPurge 的对象,继承自 FRunnable,新起一个线程,专门用于删除垃圾

class FAsyncPurge : public FRunnable {
	// 一些成员属性

	// 一些成员函数
	virtual uint32 Run()
	{
		AsyncPurgeThreadId = FPlatformTLS::GetCurrentThreadId();
		
		while (StopTaskCounter.GetValue() == 0)
		{
			if (BeginPurgeEvent->Wait(15, true))
			{
				BeginPurgeEvent->Reset();
				TickDestroyObjects<true>(/* bUseTimeLimit = */ false, /* TimeLimit = */ 0.0f, /* StartTime = */ 0.0);
				FinishedPurgeEvent->Trigger();
			}
		}
		FinishedPurgeEvent->Trigger();
		return 0;
	}
}

那么真正处理的逻辑,就在 TickDestroyObjects 函数中咯

bool TickDestroyObjects(bool bUseTimeLimit, double TimeLimit, double StartTime)
{
	// ... do something 

	while (ObjCurrentPurgeObjectIndex < GUnreachableObjects.Num())
		{
			FUObjectItem* ObjectItem = GUnreachableObjects[ObjCurrentPurgeObjectIndex];
			UObject* Object = (UObject*)ObjectItem->Object;
				
			{
				GUObjectArray.LockInternalArray();
				Object->~UObject();
				GUObjectArray.UnlockInternalArray();
				GUObjectAllocator.FreeUObject(Object);
				GUnreachableObjects[ObjCurrentPurgeObjectIndex] = nullptr;
			}

			// 如果在游戏线程需要判断是否分帧
		}

	// ... do something
}

上述代码逻辑很清晰,根据索引遍历 UObject,手动调用其 析构函数,然后回收其内存

GENERATED_BODY() 中会定义对象的 析构函数虚函数 ,所以不用担心内存泄漏

顺带,会判断当前是否是单线程的,如果是单线程,并且有时间限制,会判断当前清理逻辑占用时间,适时的跳出循环,也就是分帧,避免一帧占用太多时间影响游戏性能

if (!bMultithreaded && bUseTimeLimit && 
		(ProcessedObjectsCount == TimeLimitEnforcementGranularityForDeletion) && 
		(ObjCurrentPurgeObjectIndex < GUnreachableObjects.Num()))
{
	ProcessedObjectsCount = 0;
	if ((FPlatformTime::Seconds() - StartTime) > TimeLimit)
	{
		bFinishedDestroyingObjects = false;
		break;
	}				
}

防止 GC

如果不希望某个 UObject 被 GC 掉,那么调用 AddRoot 将其标记为 根节点,根据 标记 阶段的代码可以发现,如果是 根对象 会直接将其设置为可达

如果是非 UObject 对象,但是希望其也被 GC 管理,可以继承 FGCObject 结构体,该结构体会在构造的将自己注册到 GGCObjectReferencer 全局数组中

标记 阶段,一开始就会将 GGCObjectReferencer 加入到待处理数组中

命令

[/Script/Engine.GarbageCollectionSettings]
; 开启增量GC,分帧销毁对象,减少卡顿
gc.IncrementalBeginDestroyEnabled=True

; 并行GC子任务的最小对象数,适当调大提升并行效率
gc.MinDesiredObjectsPerSubTask=100

; GC触发的对象数量阈值,适当调大减少GC频率
gc.MaxObjectsNotConsideredByGC=0

; PendingKill对象清理间隔,单位秒,调大减少GC频率
gc.TimeBetweenPurgingPendingKillObjects=60

; 增量GC重试次数,调大可减少强制GC
gc.NumRetriesBeforeForcingGC=10

; 每帧最大BeginDestroy对象数,防止单帧销毁过多对象
gc.IncrementalBeginDestroyObjectsPerFrame=100

; 是否压缩GC堆,减少内存碎片
gc.AllowParallelGC=True

优化

  1. 使用 也就是 Cluster

将 Character、Weapon 等生命周期一致的对象,勾选 Cluster

  1. 尽量减少 UObject 对象的数量

比如减少蓝图中的宏、控制 Actor 数量等

  1. 采用对象池,不频繁清理和生成大的对象

最厉害的还是改源码... 比如将标记那块改为无锁的

智能指针

TWeakObjectPtr 继承自 FWeakObjectPtr,存储 UObjectGUObjectArrayIndexSerialNumber,而不是直接引用 UObject

void FWeakObjectPtr::operator=(const class UObject *Object)
{
	if (Object)
	{
		ObjectIndex = GUObjectArray.ObjectToIndex((UObjectBase*)Object);
		ObjectSerialNumber = GUObjectArray.AllocateSerialNumber(ObjectIndex);
		checkSlow(SerialNumbersMatch());
	}
	else
	{
		Reset();
	}
}

TStrongObjectPtrFGCObjectReferenceCollector,通过 FGCObject 的构造将 UObject 添加到 GGCObjectReferencer

因为 GGCObjectReferencerAddToRoot 的,所以不会被销毁,那么该智能指针指向的 UObject 也不会被销毁,在析构的时候将 UObjectGGCObjectReferencer 销毁即可

void FGCObject::RegisterGCObject()
{
	if (!IsEngineExitRequested() && !bReferenceAdded)
	{
		StaticInit();
		GGCObjectReferencer->AddObject(this);
		bReferenceAdded = true;
	}
}
void FGCObject::StaticInit()
{
	if (GGCObjectReferencer == nullptr)
	{
		GGCObjectReferencer = NewObject<UGCObjectReferencer>();
		GGCObjectReferencer->AddToRoot();
	}
}

TSoftObjectPtr 组合 FSoftObjectPtr 使用,既不是强引用指针也不是软引用指针

主要功能在 TPersistentObjectPtr 类中实现,该类维护一个 WeakPtr 用于记录指向的对象,存储一个 TObjectID 也就是 FSoftObjectPath 对象用于记录目标对象的资产路径

struct FSoftObjectPtr : public TPersistentObjectPtr<FSoftObjectPath>;

template<class TObjectID>
struct TPersistentObjectPtr
{
private:
	mutable FWeakObjectPtr	WeakPtr;
	TObjectID				ObjectID;
}

通过 TPersistentObjectPtr 获取对象可以窥见 TSoftObjectPtr 的核心功能

FORCEINLINE UObject* Get() const
{
	UObject* Object = WeakPtr.Get();
	if (!Object && ObjectID.IsValid())
	{
		Object = ObjectID.ResolveObject();
		WeakPtr = Object;
		return ::GetValid(Object);
	}
	return Object;
}