UE4 GC机制解析(三):标记与清除

GC锁、可达性分析

Posted by bbkgl on August 3, 2021

故乡云水地

归梦不宜秋

继续接上文,分析UE GC的mark和sweep。

可达性分析

通过调用 FRealTimeGC::PerformReachabilityAnalysis() 实现对所有 UObject 的可达性分析。

void PerformReachabilityAnalysis(EObjectFlags KeepFlags, bool bForceSingleThreaded, bool bWithClusters)
{
    /** Growing array of objects that require serialization */
    FGCArrayStruct* ArrayStruct = FGCArrayPool::Get().GetArrayStructFromPool();
    TArray<UObject*>& ObjectsToSerialize = ArrayStruct->ObjectsToSerialize;

    // Reset object count.
    GObjectCountDuringLastMarkPhase.Reset();

    // 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();
        (this->*MarkObjectsFunctions[GetGCFunctionIndex(!bForceSingleThreaded, bWithClusters)])(ObjectsToSerialize, KeepFlags);
        UE_LOG(LogGarbage, Verbose, TEXT("%f ms for MarkObjectsAsUnreachable Phase (%d Objects To Serialize)"), (FPlatformTime::Seconds() - StartTime) * 1000, ObjectsToSerialize.Num());
    }

    {
        const double StartTime = FPlatformTime::Seconds();
        PerformReachabilityAnalysisOnObjects(ArrayStruct, bForceSingleThreaded, bWithClusters);
        UE_LOG(LogGarbage, Verbose, TEXT("%f ms for Reachability Analysis"), (FPlatformTime::Seconds() - StartTime) * 1000);
    }

    // Allowing external systems to add object roots. This can't be done through AddReferencedObjects
    // because it may require tracing objects (via FGarbageCollectionTracer) multiple times
    FCoreUObjectDelegates::TraceExternalRootsForReachabilityAnalysis.Broadcast(*this, KeepFlags, bForceSingleThreaded);

    FGCArrayPool::Get().ReturnToPool(ArrayStruct);

    #if UE_BUILD_DEBUG
    FGCArrayPool::Get().CheckLeaks();
    #endif
}

注意到定义了一个 FGCArrayStruct 类型的局部变量 ArrayStruct,用于存储需要序列化的 UObject。这个变量通过 FGCArrayPool::GetArrayStructFromPool() 返回,在 MarkObjectsAsUnreachable() 也可以见到这样的用法。 ArrayStruct->ObjectsToSerialize 是一个长度持续增长的序列,在整个可达性分析的过程中存储所有可达的UObject指针。我感觉是通过存在这样的一个序列池,内存连续,用于在可达性分析阶段存储 UObject 指针,以减小cache miss。

首先将 FGCObject::GGCObjectReferencer 加入到了 ObjectsToSerialize 中, FGCObject::GGCObjectReferencer 是类 FGCObject 中的一个静态成员,其充当了一座联系 FGCObjectUObject 的桥梁,可以让继承于 FGCObject 的对象,通过调用 AddReferencedObjects() 将自己纳入到可达性分析中。其实就是可以让 FGCObject 子类的一些 UObject 成员可以被可达性分析判定为可达,防止成员中的 UObject 被清理掉,所以会看到类 GGCObjectReferencer 中有一个 TArray<FGCObject *> 的成员,用于可达性分析中遍历每个 FGCObject 对象。这件事虽然说起来简单,但UE中实现却相当复杂。

后面两处代码块中,一处会遍历所有的 UObject,试图将它们都标记为不可达,明确可达或者特殊的UObject的放入到 ObjectsToSerialize 中,所以可以看到传的是引用。

另外一处就是可达性分析,可达性分析中会逐渐将所有可达的 UObject 添加到 ArrayStruct->ObjectsToSerialize 中,不可达的则标记不可达。

MarkObjectsAsUnreachable

FRealTimeGC::PerformReachabilityAnalysis() 中调用的是 *MarkObjectsFunctions[index],这里得到的是一个函数指针。

/** Default constructor, initializing all members. */
FRealtimeGC()
{
    MarkObjectsFunctions[GetGCFunctionIndex(false, false)] = &FRealtimeGC::MarkObjectsAsUnreachable<false, false>;
    MarkObjectsFunctions[GetGCFunctionIndex(true, false)] = &FRealtimeGC::MarkObjectsAsUnreachable<true, false>;
    MarkObjectsFunctions[GetGCFunctionIndex(false, true)] = &FRealtimeGC::MarkObjectsAsUnreachable<false, true>;
    MarkObjectsFunctions[GetGCFunctionIndex(true, true)] = &FRealtimeGC::MarkObjectsAsUnreachable<true, true>;
    ...
}

通过对非类型参数的函数模板 MarkObjectsAsUnreachable 实例化得到具体函数的指针,将函数指针的地址存储到 MarkObjectsFunctions 数组中,就得到了四种情况下的 MarkObjectsAsUnreachable() 函数,也就是 是否多线程和是否使用Cluster的组合。

template <bool bParallel, bool bWithClusters>
void MarkObjectsAsUnreachable(TArray<UObject*>& ObjectsToSerialize, const EObjectFlags KeepFlags)
{
    ...
}

函数内部代码比较长,就不放代码一行一行分析了,介绍下重要流程。

函数功能:按照既定规则,将 UObject 加入到 ObjectsToSerializeArrays 中或者标记为 Unreachable 不可达,也就是认为可达的 UObject 都添加到了 ObjectsToSerializeArrays 中。

认为可达的 UObject 符合以下条件之一:

  • 属于 root set
  • 包含 EInternalObjectFlags::GarbageCollectionKeepFlags
  • 不在 pending kill 状态,且包含 KeepFlags,且 KeepFlags != 0

当然除此之外还有Cluster的处理,这不是重点。过程使用了 UE 的多线程框架并行处理和标记,详见 ParallelFor

最终在可达性分析之前得到了部分可达对象序列 ObjectsToSerializeArrays

PerformReachabilityAnalysisOnObjects

实际调用链条为:

image-20210804164238100

也是根据是否使用多线程来决定调用路径的,如果是多线程就会用到类 TFastReferenceCollector::FCollectorTaskQueue 的多线程并行机制。

image-20210804172015033

不过最终都会调用到 TFastReferenceCollector::ProcessObjectArray() 这个函数,多线程则是将 可达的UObject 序列分到不同的线程执行可达性分析。这种情况下,性能就取决于最慢的那个线程了。当然还有个问题,就是线程同步的成本会不会也相当大呢?暂时挖坑,以后分析UE的多线程相关的实现。

以下分析 ProcessObjectArray() 的实现,这个函数的实现并不在 GarbageCollection.cpp 中,而是在 FastReferenceCollection.h 中。

简单描述该函数的功能:通过遍历ObjectsToSerialize,进行广度搜索(部分类型会深度有限搜索),将可达的UObject指针不断添加到ObjectsToSerialize中,直到ObjectsToSerialize中所有的UObject都被访问过。这样就将可达的UObject对象全部收集好了,未收集的即为不可达。

看到定义了一个Stack时,我以为会使用栈模拟递归深搜来收集UObject,但实际上只有部分类型才会模拟递归,否则的话就直接加入到NewObjectsToSerialize中,像是广度优先搜索和深度优先搜索结合。

image-20210805151810249

其中遍历完当前的 ObjectsToSerialize 后,一轮遍历就完成了。顺利的话,就会将 NewObjectsToSerializeObjectsToSerialize 进行交换,下一轮中就会遍历 “原NewObjectsToSerialize”,往 “原ObjectsToSerialize” 中添加元素。

而如果 NewObjectsToSerialize 数量大于一个Task可以处理的数量,就会将 NewObjectsToSerialize 再次分给1到多个线程去遍历。

image-20210805155047571

对于每个UObject,会遍历其对应的 TokenStream ,也就是 GC信息收集 中介绍的引用收集得到的序列。然后通过偏移量访问到每个UObject的子UObject,判断其类型。类型处理主要分为三种:

  • 一种就是普通的UObject,这种情况下就直接将子UObject添加到 NewObjectsToSerialize 中,不继续递归处理,同时为其添加到引用。
  • 第二种就是ArrayStruct类型,这种情况下会递归扫描序列中的UObject。
  • 还有就是需要调用 AddReferencedObjects 添加引用的,这个在前面提到过

array/struct类型的处理,也可以在函数前面部分的for循环看到。

image-20210805160132763

最终每个可达的UObject都会在函数 HandleObjectReference() 中处理,清除掉不可达标记,如果是多线程,则需要通过原子操作判断是否已经清除过,过程不再赘述。

image-20210805172745227

这里还有个疑问,被 UPROPERTY 修饰的成员,如果被 GC 回收了,如何将对应的指针置空的呢?

很容易想到,既然可达性分析的时候会遍历所有引用,也就是能拿到每个成员的偏移量和实际的虚拟内存地址,这样情况下,将对应的地址上8(64bit)字节的内容直接置为0就可以了。当然这种方法相当暴力,UE4是直接传了一个指针的引用,这样的话就能修改指针的指向了,具体的实现在 HandleObjectReference() 中,可以看到参数Object是个指针类型的引用,自然可以修改指针的指向,其实就是修改指针记录的地址。

FORCEINLINE void HandleObjectReference(TArray<UObject*>& ObjectsToSerialize, const UObject * const ReferencingObject, UObject*& Object, const bool bAllowReferenceElimination)
{
    ...
    const int32 ObjectIndex = GUObjectArray.ObjectToIndex(Object);
    FUObjectItem* ObjectItem = GUObjectArray.IndexToObjectUnsafeForGC(ObjectIndex);
    // Remove references to pending kill objects if we're allowed to do so.
    if (ObjectItem->IsPendingKill() && bAllowReferenceElimination)
    {
        //checkSlow(ObjectItem->HasAnyFlags(EInternalObjectFlags::ClusterRoot) == false);
        checkSlow(ObjectItem->GetOwnerIndex() <= 0)

            // Null out reference.
            Object = NULL;
    }

收集不可达对象

收集不可达对象的操作在函数 GatherUnreachableObjects 中执行。里面有Cluster的解组操作,但这篇文章不对这个展开讲。

函数的主要功能:(并行)遍历 GUObjectArray ,将所有标记为不可达的UObject加入到 GUnreachableObjects 中。

从源码中看出来,这个过程是直接访问的 GUObjectArray ,这样确实需要加锁,GameThread必须等待这个过程完成,否则可能会访问冲突。不过我在想,如果在前面过程中将所有标记不可达的时候就复制一份,因为全部是指针,直接memcopy。这样后续清理包括这个收集过程不就不需要再去访问这个 GUObjectArray 了吗,然后再分帧一同清理掉。进一步可以做成双Buffer,这样就每次swap一下就行。

不可达对象清理

从我卑微又弱小的角度来看,清理过程应该就是几行代码:

for (auto &objectPtr : unreachableList) {
    delete objectPtr;
}

但是UE里的实现却较为复杂,我想至少应该考虑以下几个方面:

  • 分帧清理降低性能影响
  • 多线程加速
  • Cluster(簇)
  • 清理UObjectHashTables 等全局的UObject表以及其他引用

可达性分析的标记过程结束后,便会将所有不可达对象收集起来,在清理阶段调用 IncrementalPurgeGarbage() 函数执行真正的清理过程。

IncrementalPurgeGarbage

前面已经说到了,清理过程不一定是分帧增量的,仅在 ConditionalCollectGarbage() 中调用时才会执行分帧清理,否则全部为全量清理。且Editor下一定是全量清理,因为mark结束后就会直接全量清理,所以后续Tick中都不会执行 ConditionalCollectGarbage()

1629125547145

函数源码中,如果之前已经全部清理完成,则进入 IncrementalPurgeGarbage() 后会立刻return。

void IncrementalPurgeGarbage(bool bUseTimeLimit, float TimeLimit)
{
    ...
	// Early out if there is nothing to do.
	if (!GObjPurgeIsRequired)
	{
		return;
	}
    ...
}

确定执行清理后,代码中会定义一个临时的 FResetPurgeProgress 结构体 ResetPurgeProgress

struct FResetPurgeProgress
{
    bool& bCompletedRef;
    FResetPurgeProgress(bool& bInCompletedRef)
        : bCompletedRef(bInCompletedRef)
        {
            // Incremental purge is now in progress.
            GObjIncrementalPurgeIsInProgress = true;
            FPlatformMisc::MemoryBarrier();
        }
    ~FResetPurgeProgress()
    {
        if (bCompletedRef)
        {
            GObjIncrementalPurgeIsInProgress = false;
            FPlatformMisc::MemoryBarrier();
        }
    }

} ResetPurgeProgress(bCompleted);

显然这是个局部变量,利用栈变量的生命周期进行某些初始化和”反初始化“。在构造函数中标识清理过程开始执行,并通过内存屏障保证标识动作完成,在析构函数中进行标识的逆操作。这种类似于 RAII 的资源管理方式在UE4中大量使用,同时 static 懒加载也大量被使用。

紧接着就会调用 UnhashUnreachableObjects() 函数执行真正的 UObject 清理。

{
    // Lock before settting GCStartTime as it could be slow to lock if async loading is in progress
    // but we still want to perform some GC work otherwise we'd be keeping objects in memory for a long time
    FConditionalGCLock ScopedGCLock;

    // Keep track of start time to enforce time limit unless bForceFullPurge is true;
    GCStartTime = FPlatformTime::Seconds();
    bool bTimeLimitReached = false;

    if (GUnrechableObjectIndex < GUnreachableObjects.Num())
    {
        bTimeLimitReached = UnhashUnreachableObjects(bUseTimeLimit, TimeLimit);

        if (GUnrechableObjectIndex >= GUnreachableObjects.Num())
        {
            FScopedCBDProfile::DumpProfile();
        }
    }

    if (!bTimeLimitReached)
    {
        bCompleted = IncrementalDestroyGarbage(bUseTimeLimit, TimeLimit);
    }
}

上面这段代码里虽然显式调用了 FConditionalGCLock 的构造函数试图获取GC锁,但并不是像之前的会等待获取到锁才执行 scope 里的逻辑。这里如果没拿到锁也会继续执行,不会等,也不会退出函数。

/** Locks GC within a scope but only if it hasn't been locked already */
struct FConditionalGCLock
{
	bool bNeedsUnlock;
	FConditionalGCLock()
		: bNeedsUnlock(false)
	{
		if (!FGCCSyncObject::Get().IsGCLocked())
		{
			AcquireGCLock();
			bNeedsUnlock = true;
		}
	}
	~FConditionalGCLock()
	{
		if (bNeedsUnlock)
		{
			ReleaseGCLock();
		}
	}
};

GUnrechableObjectIndex 是一个全局的index,会记录下一个要被的 UObject 下标,主要用于增量清理时下一次清理可以接上次的进度继续执行。

在GC所的Scope内主要执行两个逻辑:

  • UnhashUnreachableObjects() ,该函数内会调用每个UObject对象的 ConditionalBeginDestroy() 函数,使用 GUnrechableObjectIndex 记录下一个要调用 ConditionalBeginDestroy() 的UObject的下标,最终会调用到 BeginDestroy() 函数;
  • IncrementalDestroyGarbage() ,该函数内部会调用每个UObject对象的 ConditionalFinishDestroy() 函数,使用 GObjCurrentPurgeObjectIndex 记录下一个要调用 ConditionalFinishDestroy() 的UObject的下标,最终会调用到 FinishDestroy() 函数

为什么要分这两个步骤呢?

其实 BeginDestroy() 更像是对清理流程的通知,让UObject需要在其他线程进行的清理工作启动,异步清理结束后,FinishDestroy() 才能被调用。调用FinishDestroy() 前,先判断UObject自己的清理工作是否已经完成,否则会保存到列表 GGCObjectsPendingDestruction 里。后续会继续尝试调用 GGCObjectsPendingDestruction 中每个 UObject 的FinishDestroy()

1629617877656

第二次尝试调用 FinishDestroy() 有两种情况:

  • 如果是分帧清理,则遍历 GGCObjectsPendingDestructionCount 调用 FinishDestroy()的操作会留到下帧再执行;
  • 如果是全量清理,则直接遍历 GGCObjectsPendingDestructionCount ,对每个 UObject 等待其异步清理工作完成,最终调用FinishDestroy(),一般是等待渲染线程完成。

这里其实能看出来,无论是 BeginDestroy() 还是 FinishDestroy() 其实都是一种通知机制,不是真正执行清理逻辑。执行 FinishDestroy() ,就到了调用析构函数,释放内存的部分了。

详细的逻辑可以在 TickDestroyGameThreadObjects() 中找到,依然会根据 bUseTimeLimit 判断是否要分帧清理。

1629618691046

1629618999151

最终每个UObject都会在这两个地方调用析构函数,值得注意的是,这部分逻辑可能会在GameThread中执行,也可能在异步线程中执行,具体会在生成 FAsyncPurge 对象的时候就决定好。

1629619108727

至此,mark 和 sweep就都介绍完了。