天若有情天亦老
人间正道是沧桑
看了很多资料,很多文章都写得很优秀,但是没怎么见到有文章比较详细分析为什么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使用了如下方法:
- 限制/警告开发者不能在非game线程操作UObject
- GC逻辑直接放在Game Thread执行,GC前后加锁,使用内存屏障,保证加锁前对UObject的读写操作均已完成
- 引擎中其他线程如有查找和增加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
。
继续查看 LockUObjectHashTables()
的实现,注释里写明了防止其他线程增加或者查找UObject。
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
中调用:
而函数 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)
, 这里是一定会增量执行清除的。
增量/全量清除通过 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()
调用是增量清除,其余全是全量清除。
增量清除正好是在 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作为可选项,难道不是必须项吗?
梳理源码,得到函数 CollectGarbageInternal()
的执行流程如下图:
图中虚线表示不一定执行的调用路径,实线表示一定执行,可以总结如下几点:
- 在
CollectGarbageInternal()
中,可达性分析、不可达对象收集这两个过程一定会执行;而全量/增量sweep以及对不可达对象的Unhash操作则不一定会执行 - 大部分逻辑执行中,
UObjectHashTables
都会被锁住,其他线程无法操作 - 整个过程都会造成Game Thread同步阻塞
后面文章准备分析mark和sweep。