UE4 GC机制解析(二):GC锁与GC流程

GC锁、GC流程

Posted by bbkgl on July 13, 2021

天若有情天亦老

人间正道是沧桑

看了很多资料,很多文章都写得很优秀,但是没怎么见到有文章比较详细分析为什么GC的时候需要Stop The World。以及其他语言可以通过在字节码中插入safe point,或者直接通过字节码hook实现stop the world,UE4 又如何在C++上实现这样的操作?

UObject 与GC 锁:UE4 GC 无 STW

带着问题写文章。

GC时,为了避免GC线程与其他线程同时操作对象,会执行stop the world,也就是除了GC线程外,其他线程都会停止。

很显然,C#/Java/Python等编程语言都可以通过在字节码中插入 safepoint 来实现 stop the world。虚拟机虽然牺牲了部分性能,但提供了极大的灵活性。

可是,UE4 C++没有虚拟机,执行的不是字节码,如何实现 stop the world?

我一开始想的是通过 signal 实现。。。但最终发现,UE4 GC 根本没有真正意义上的stop the world。

为了保证对UObject的安全GC,UE4使用了如下方法:

  1. 限制/警告开发者不能在非game线程操作UObject
  2. GC逻辑直接放在Game Thread执行,GC前后加锁,使用内存屏障,保证加锁前对UObject的读写操作均已完成
  3. 引擎中其他线程如有查找和增加UObject的地方都有加锁

也就是通过GC逻辑与游戏逻辑先后顺序执行,使得二者执行不冲突;以及通过GC锁使得其他获取锁的地方也阻塞。

这算stop the world吗?我觉得不算。。。

Stop The World?

GC锁会在Game Thread中使用,也就是 UWorld::Tick() 中调用 CollectGarbage()。而在函数 ::CollectGarbage() 中,会在执行GC调用的前后加锁。

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();
}

AcquireGCLock() 中继续调用 FGCCSyncObject::Get().GCLock(),其中 FGCCSyncObject 是一个单例:

void AcquireGCLock()
{
	const double StartTime = FPlatformTime::Seconds();
	FGCCSyncObject::Get().GCLock();
	const double ElapsedTime = FPlatformTime::Seconds() - StartTime;
	if (FPlatformProperties::RequiresCookedData() && ElapsedTime > 0.001)
	{
		UE_LOG(LogGarbage, Warning, TEXT("%f ms for acquiring GC lock"), ElapsedTime * 1000);
	}
}

GCLock() 中,会循环等待获取锁,

/** Lock for GC. Will block if any other thread has locked. */
void GCLock()
{
    ...
    do
    {
        ...
        {
            FScopeLock CriticalLock(&Critical);
            ....
        }
    } while (!bLocked);
}

而其他线程加载资源的时候,会调用 FGCScopeGuard ScopeGuard(),实际里面调用的也是 FGCCSyncObject::Get().LockAsync()

FGCScopeGuard::FGCScopeGuard()
{
#if UE_LOG_FGCScopeGuard_LockAsync_Time
	const double StartTime = FPlatformTime::Seconds();
#endif
	FGCCSyncObject::Get().LockAsync();
#if UE_LOG_FGCScopeGuard_LockAsync_Time
	const double ElapsedTime = FPlatformTime::Seconds() - StartTime;
	if (FPlatformProperties::RequiresCookedData() && ElapsedTime > 0.001)
	{
		// Note this is expected to take roughly the time it takes to collect garbage and verify GC assumptions, so up to 300ms in development
		UE_LOG(LogGarbage, Warning, TEXT("%f ms for acquiring ASYNC lock"), ElapsedTime * 1000);
	}
#endif
}

而这个函数,也是循环等待获取锁,同样通过调用 FScopeLock CriticalLock(&Critical) 加锁,为了实现和GC过程互斥访问。

/** Lock on non-game thread. Will block if GC is running. */
void LockAsync()
{
    if (!IsInGameThread())
    {
        // Wait until GC is done if it was running when entering this function
        bool bLocked = false;
        do
        {
            if (GCCounter.GetValue() > 0)
            {
                GCUnlockedEvent->Wait();
            }
            {
                FScopeLock CriticalLock(&Critical);
                if (GCCounter.GetValue() == 0)
                {
                    AsyncCounter.Increment();
                    bLocked = true;
                }
            }
        } while (!bLocked);
    }
}

也就是说其他操作 UObject 的线程和 GC 所在的 Game 线程二者是通过单例 FGCCSyncObject 的锁操作实现互斥访问的,所以 UE4 GC 没有 stop the world 一说,读者完全可以测试在其他线程循环操作某个 UObject ,其间不会被打断。

FGCCSyncObject 整个类的实现都比较简单,读者可自行翻阅源码。

继续深究,就会发现在 CollectGarbageInternal() 函数中还有一处加锁了。

void CollectGarbageInternal(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
#if !UE_WITH_GC
	return;
#else
	...

	{
		// Set 'I'm garbage collecting' flag - might be checked inside various functions.
		// This has to be unlocked before we call post GC callbacks
		FGCScopeLock GCLock;

		...

		// Set flag to indicate that we are relying on a purge to be performed.
		GObjPurgeIsRequired = true;

		// Perform a full purge by not using a time limit for the incremental purge. The Editor always does a full purge.
		if (bPerformFullPurge || GIsEditor)
		{
			IncrementalPurgeGarbage(false);
		}

		if (bPerformFullPurge)
		{
			ShrinkUObjectHashTables();
		}

		// Destroy all pending delete linkers
		DeleteLoaders();

		// Trim allocator memory
		FMemory::Trim();
	}
#endif	// UE_WITH_GC
}

定义了一个 FGCScopeLock 类型的局部变量,对内存敏感的人可能会发现这是个局部栈上变量,也就是说这个代码块执行结束后,就会被析构。

继续查看 FGCScopeLock 的实现,非常简短,注释里明明白白写着用于GC时锁住所有的 UObject hash tables

image-20210730165311547

继续查看 LockUObjectHashTables() 的实现,注释里写明了防止其他线程增加或者查找UObject。

image-20210730165529750

UObjectHash的锁与UObject的生死

本着求是的精神,继续翻看 UE 是如何“锁住 UObjectHash”的,前面已经提到源代码里注释里写明了 LockUObjectHashTables() 防止其他线程增加或者查找UObject。

也就是说最终是通过调用 FUObjectHashs::Lock() 实现加锁的,那就意味着其他线程增加或者查找 UObjectHash 中的 UObject 时,也要执行类似的操作加锁。

首先就找到了查找UObject的函数 StaticFindObjectFastInternalThreadSafe(),果不其然发现了 FHashTableLock HashLock(ThreadHash) 的调用。

UObject* StaticFindObjectFastInternalThreadSafe(FUObjectHashTables& ThreadHash, const UClass* ObjectClass, const UObject* ObjectPackage, FName ObjectName, bool bExactClass, bool bAnyPackage, EObjectFlags ExcludeFlags, EInternalObjectFlags ExclusiveInternalFlags)
{
	ExclusiveInternalFlags |= EInternalObjectFlags::Unreachable;

	// If they specified an outer use that during the hashing
	UObject* Result = nullptr;
	if (ObjectPackage != nullptr)
	{
		int32 Hash = GetObjectOuterHash(ObjectName, (PTRINT)ObjectPackage);
		FHashTableLock HashLock(ThreadHash);
		for (TMultiMap<int32, class UObjectBase*>::TConstKeyIterator HashIt(ThreadHash.HashOuter, Hash); HashIt; ++HashIt)
		{
			...
		}

#if WITH_EDITOR
		// if the search fail and the OuterPackage is a UPackage, lookup potential external package
		if (Result == nullptr && ObjectPackage->IsA(UPackage::StaticClass()))
		{
			Result = StaticFindObjectInPackageInternal(ThreadHash, ObjectClass, static_cast<const UPackage*>(ObjectPackage), ObjectName, bExactClass, ExcludeFlags, ExclusiveInternalFlags);
		}
#endif
	}
	else
	{
		...
	}
	// Not found.
	return Result;
}

继续看 class FHashTableLock 的实现,发现在构造函数中执行了 InTables.Lock()

class FHashTableLock
{
#if THREADSAFE_UOBJECTS
	FUObjectHashTables* Tables;
#endif
public:
	FORCEINLINE FHashTableLock(FUObjectHashTables& InTables)
	{
#if THREADSAFE_UOBJECTS
		if (!(IsGarbageCollecting() && IsInGameThread()))
		{
			Tables = &InTables;
			InTables.Lock();
		}
		else
		{
			Tables = nullptr;
		}
#else
		check(IsInGameThread());
#endif
	}
	FORCEINLINE ~FHashTableLock()
	{
#if THREADSAFE_UOBJECTS
		if (Tables)
		{
			Tables->Unlock();
		}
#endif
	}
};

实际上,InTables.Lock() 就是 FUObjectHashS::Lock()

既然查找UObject要加锁,那增删UObject肯定也有加锁。

UE在增删UObject时,分别调用 HashObject()/UnHashObject(),二者内部都会有加锁操作。

void HashObject(UObjectBase* Object)
{
	SCOPE_CYCLE_COUNTER( STAT_Hash_HashObject );

	FName Name = Object->GetFName();
	if (Name != NAME_None)
	{
		int32 Hash = 0;

		FUObjectHashTables& ThreadHash = FUObjectHashTables::Get();
		FHashTableLock HashLock(ThreadHash);

		Hash = GetObjectHash(Name);				
		...
		AddToClassMap( ThreadHash, Object );
	}
}

/**
 * Remove an object to the name hash tables
 *
 * @param	Object		Object to remove from the hash tables
 */
void UnhashObject(UObjectBase* Object)
{
	SCOPE_CYCLE_COUNTER(STAT_Hash_UnhashObject);

	FName Name = Object->GetFName();
	if (Name != NAME_None)
	{
		int32 Hash = 0;
		int32 NumRemoved = 0;

		FUObjectHashTables& ThreadHash = FUObjectHashTables::Get();
		FHashTableLock LockHash(ThreadHash);

		Hash = GetObjectHash(Name);
        ...
		RemoveFromClassMap( ThreadHash, Object );
	}
}

同样是调用 FHashTableLock LockHash(ThreadHash) 实现的,介于每次操作都会将整个 FUObjectHashTables 锁住,也就是说锁的粒度其实很大,增删改查一个UObject,几个Hash Table都会被锁住。

到了这里就可以下结论了,不同线程对同一个 FUObjectHashTables 操作会通过加锁来实现互斥访问,但无法阻挡其他线程对UObject的修改操作,因为UE用的是裸指针,而不是wrap后的指针。GC也就借助了这样的机制来进一步实现假的 “stop the world”。

GC与裸指针

还是尝试讲一下裸指针与GC的关系。

为什么要讲这个,因为裸指针对对象的操作是无法被追踪的。这会给GC带来一个问题:无法实现真正的互斥访问。

前面介绍的GC锁和UObjectHash的锁都只能阻止其他线程对 UObjectHash 的访问,也就是无法增删查UObject,但是无法阻止对UObject指针指向的内存区域的修改。因为裸指针的 -> 符号依然是自由的,依然可以通过 ptr->XXX 修改成员变量或者调用成员函数。同时裸指针的操作过程对于GC系统来说是完全透明的,无法追踪和记录。在UE中,完全可以自己在一个Thread中死循环访问某个UObject的成员,即便这个UObject将立即被释放。

裸指针带给GC这么多麻烦,UE为什么依然这么设计?

唯一的解释就是性能,裸指针用较低抽象层次换来极致的性能。

CollectGarbage 执行流程

在前面文章的基础上,后续介绍 GC 流程就和其他文章没什么太大区别了,主要是两个部分,可达性分析和 UObject 清除,也就是mark和sweep。从GC入口的代码来看,整个GC的流程就不止 ”mark + swepp“ 能说清楚的。所以,还是先梳理下流程。

GC入口

首先明确UE4 GC是使用了 全量mark + 增量(可选)sweep 实现GC的。sweep的执行相对灵活,虽然每帧都会执行,但有可能是全量,也可能是增量,也可能不真正执行(调用了但直接退出)。而mark的过程则不一定每帧都执行,执行则一定是全量。

还有一个重点,目前UE GC的所有过程,都是阻塞游戏线程的。虽然可能会在某些过程中使用多线程并行执行,但这对于游戏线程来说,依然是同步的,因为游戏线程会等待整个GC过程完成。比如sweep部分,虽然经过标记后,需要清扫的UObject已经不会再被游戏线程和其他线程操作了,但UE4依然把sweep过程放在了游戏线程中执行,且会同步等待该过程执行完成(是不是可以优化)。

先看下mark的过程,mark过程会在函数 CollectGarbageInternal 中调用:

image-20210802163621263

而函数 CollectGarbageInternal 会在 CollectGarbage()TryCollectGarbage() 中调用。

CollectGarbage() 就三行代码,GC锁在前面已经解释了,不再赘述。

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();
}

TryCollectGarbage() 函数也不是很长, CollectGarbage() 执行是一定会获取到锁,而TryCollectGarbage() 会尝试获取锁,超过一定次数未获取到才会强制阻塞获取到锁。这也就意味着 TryCollectGarbage() 不一定会执行 GC 过程。

bool TryCollectGarbage(EObjectFlags KeepFlags, bool bPerformFullPurge)
{
	// No other thread may be performing UObject operations while we're running
	bool bCanRunGC = FGCCSyncObject::Get().TryGCLock();
	if (!bCanRunGC)
	{
		if (GNumRetriesBeforeForcingGC > 0 && GNumAttemptsSinceLastGC > GNumRetriesBeforeForcingGC)
		{
			// Force GC and block main thread			
			UE_LOG(LogGarbage, Warning, TEXT("TryCollectGarbage: forcing GC after %d skipped attempts."), GNumAttemptsSinceLastGC);
			GNumAttemptsSinceLastGC = 0;
			AcquireGCLock();
			bCanRunGC = true;
		}
	}
	if (bCanRunGC)
	{
		// Perform actual garbage collection
		CollectGarbageInternal(KeepFlags, bPerformFullPurge);

		// Other threads are free to use UObjects
		ReleaseGCLock();
	}
	else
	{
		GNumAttemptsSinceLastGC++;
	}

	return bCanRunGC;
}

这两个函数都会在 ConditionCollectGarbage() 中调用,且一次只有其中一个会被调用, ConditionCollectGarbage() 是在 Tick() 中每帧执行的。这个函数里还会执行增量sweep的逻辑 IncrementalPurgeGarbage(true), 这里是一定会增量执行清除的。

image-20210802170516643

image-20210802192223664

增量/全量清除通过 IncrementalPurgeGarbage() 调用来实现。

/**
 * Incrementally purge garbage by deleting all unreferenced objects after routing Destroy.
 *
 * Calling code needs to be EXTREMELY careful when and how to call this function as 
 * RF_Unreachable cannot change on any objects unless any pending purge has completed!
 *
 * @param	bUseTimeLimit	whether the time limit parameter should be used
 * @param	TimeLimit		soft time limit for this function call
 */
COREUOBJECT_API void IncrementalPurgeGarbage( bool bUseTimeLimit, float TimeLimit = 0.002 );

从函数声明中就能看出来,默认情况下,每次sweep的执行时间限制为2ms,第一个参数决定是全量还是增量sweep。

在UE 源码中,只有一处的 IncrementalPurgeGarbage() 调用是增量清除,其余全是全量清除。

image-20210802174008085

增量清除正好是在 ConditionCollectGarbage() 中调用,也就是说仅有在正常UE Tick的逻辑中自动调用的GC才会执行增量sweep,否则就是全量的sweep。当然 IncrementalPurgeGarbage() 就算调用了,也未必会真正执行sweep,每次sweep一定是在mark发生之后,可以关注下 GObjPurgeIsRequired 变量。 GObjPurgeIsRequired == true 时,sweep才真正发生,否则函数 IncrementalPurgeGarbage() 会直接return。而正常情况下,在mark后, GObjPurgeIsRequired 才会置为 true,在sweep结束后,才会置为 false。其他全量的sweep分别发生的场景:

  • 两次mark之间一定会执行一次 IncrementalPurgeGarbage() sweep,可以关注下 CollectGarbageInternal() 中会将 GObjPurgeIsRequired 置为 true,所以后续在进入 CollectGarbageInternal() 后如果此前没有执行过全量或增量sweep,就会执行全量的sweep
  • 手动强制GC会在下一帧执行mark + 全量的sweep
  • 规定了强制每一帧GC也会mark + 全量的sweep
  • Editor模式中,mark结束后必定进行一次全量的sweep
  • Editor模式下,仅进行全量sweep,没有增量分帧sweep。因为Editor模式下,mark后必定全量sweep,这样GObjPurgeIsRequired 会置为 false,就算Tick中调用则函数 IncrementalPurgeGarbage() ,也会直接return。这点有些文章讲错了,所以强调一下。
  • 还有会在Obj.cpp调用的情况,这个比较迷,可能是游戏退出时调用的

至于mark是否会进行,根据源码来看,只要调用 CollectGarbageInternal() 就会执行可达性分析、标记以及收集引用的过程。而 CollectGarbageInternal() 分别在CollectGarbage()TryCollectGarbage() 调用:

  • ConditionCollectGarbage() -> TryCollectGarbage() ,每帧未必调用,调用了在未获取到锁的情况下也不执行,两种情况
  • 第一种是取决于 bFullPurgeTriggered 变量是否为 true,基本上会在调用了 ForceGarbageCollection(true) 后,bFullPurgeTriggered == true ,当然这也意味着全量的sweep
  • 第二种是时间条件;UE使用了一个 TimeSinceLastPendingKillPurge 变量记录距离上次清理 pending kill对象的时间,这个时间如果超过一个由GetTimeBetweenGarbageCollectionPasses() 得到的值就会执行TryCollectGarbage()GetTimeBetweenGarbageCollectionPasses() 会同时考虑当前可用的物理内存和距离和预定的GC间隔时间来控制每次GC的间隔时间(默认60s)。
  • ConditionCollectGarbage() -> CollectGarbage() ,每帧自动调用,在压力测试以及规定了每帧GC的情况下一定执行,否则需要主动调用
  • 其他情况调用 CollectGarbage()TryCollectGarbage() 都属于引擎主动调用的情况,而不是随游戏帧调用

CollectGarbageInternal() 执行流程

GC 函数CollectGarbage()TryCollectGarbage() 在获取到锁后最终都会调用到 CollectGarbageInternal(),所以重点关注该函数内部执行流程。

void CollectGarbageInternal(EObjectFlags KeepFlags, bool bPerformFullPurge);

参数中 KeepFlags 会用于在可达性分析中比较 UObject 是否包含该KeepFlags,用于分析对象是否要被标记为不可达,bPerformFullPurge 用于判断这次是否要执行全量的sweep。

有个非常迷惑的地方,UE在2021 5.1号的某次commit中加入了宏 UE_WITH_GC,这是要将GC作为可选项,难道不是必须项吗?

image-20210803112029117

梳理源码,得到函数 CollectGarbageInternal() 的执行流程如下图:

2333

图中虚线表示不一定执行的调用路径,实线表示一定执行,可以总结如下几点:

  • CollectGarbageInternal() 中,可达性分析、不可达对象收集这两个过程一定会执行;而全量/增量sweep以及对不可达对象的Unhash操作则不一定会执行
  • 大部分逻辑执行中,UObjectHashTables 都会被锁住,其他线程无法操作
  • 整个过程都会造成Game Thread同步阻塞

后面文章准备分析mark和sweep。