微微风簇浪
散作满河星
标记-清除的GC机制要包括以下四个流程。
接下来介绍UE4中的信息收集过程。
引擎加载流程中收集信息
由于内存信息,包括类型信息在编译期就已经确定,所以在反射支持的情况下,便能够在初始化的时候进行收集。之前介绍反射时,有讲到引擎加载的流程中会调用 ProcessNewlyLoadedUObjects()
函数(多次)。
而 ProcessNewlyLoadedUObjects()
函数中会调用 UClass::AssembleReferenceTokenStreams()
,后者是一个静态函数,直接通过类名调用,代码也很简短:
void UClass::AssembleReferenceTokenStreams()
{
SCOPED_BOOT_TIMING("AssembleReferenceTokenStreams (can be optimized)");
// Iterate over all class objects and force the default objects to be created. Additionally also
// assembles the token reference stream at this point. This is required for class objects that are
// not taken into account for garbage collection but have instances that are.
for (FRawObjectIterator It(false); It; ++It) // GetDefaultObject can create a new class, that need to be handled as well, so we cannot use TObjectIterator
{
if (UClass* Class = Cast<UClass>((UObject*)(It->Object)))
{
// Force the default object to be created (except when we're in the middle of exit purge -
// this may happen if we exited PreInit early because of error).
//
// Keep from handling script generated classes here, as those systems handle CDO
// instantiation themselves.
if (!GExitPurge && !Class->HasAnyFlags(RF_BeingRegenerated))
{
Class->GetDefaultObject(); // Force the default object to be constructed if it isn't already
}
// Assemble reference token stream for garbage collection/ RTGC.
if (!Class->HasAnyFlags(RF_ClassDefaultObject) && !Class->HasAnyClassFlags(CLASS_TokenStreamAssembled))
{
Class->AssembleReferenceTokenStream();
}
}
}
}
函数的执行流程如下:
- 遍历所有的UClass(每一个用
UCLASS()
宏修饰的类都会生成一个UClass对象) - 对每个UClass生成一个默认的对象(指被UClass修饰的类的对象)
- 对每个UClass,如果没有收集gc的token stream信息,则调用每个UClass对象的
AssembleReferenceTokenStream()
函数(非静态)
所以继续进入 AssembleReferenceTokenStream()
函数(非静态)。
void UClass::AssembleReferenceTokenStream(bool bForce)
{
// Lock for non-native classes
FScopeLockIfNotNative ReferenceTokenStreamLock(ReferenceTokenStreamCritical, !(ClassFlags & CLASS_Native));
UE_CLOG(!IsInGameThread() && !IsGarbageCollectionLocked(), LogGarbage, Fatal, TEXT("AssembleReferenceTokenStream for %s called on a non-game thread while GC is not locked."), *GetFullName());
if (!HasAnyClassFlags(CLASS_TokenStreamAssembled) || bForce)
{
if (bForce)
{
ReferenceTokenStream.Empty();
ClassFlags &= ~CLASS_TokenStreamAssembled;
}
TArray<const FStructProperty*> EncounteredStructProps;
// Iterate over properties defined in this class
for( TFieldIterator<FProperty> It(this,EFieldIteratorFlags::ExcludeSuper); It; ++It)
{
FProperty* Property = *It;
Property->EmitReferenceInfo(*this, 0, EncounteredStructProps);
}
if (UClass* SuperClass = GetSuperClass())
{
// We also need to lock the super class stream in case something (like PostLoad) wants to reconstruct it on GameThread
FScopeLockIfNotNative SuperClassReferenceTokenStreamLock(SuperClass->ReferenceTokenStreamCritical, !(SuperClass->ClassFlags & CLASS_Native));
// Make sure super class has valid token stream.
SuperClass->AssembleReferenceTokenStream();
if (!SuperClass->ReferenceTokenStream.IsEmpty())
{
// Prepend super's stream. This automatically handles removing the EOS token.
ReferenceTokenStream.PrependStream(SuperClass->ReferenceTokenStream);
}
}
else
{
UObjectBase::EmitBaseReferences(this);
}
{
check(ClassAddReferencedObjects != NULL);
const bool bKeepOuter = true;//GetFName() != NAME_Package;
const bool bKeepClass = true;//!HasAnyInternalFlags(EInternalObjectFlags::Native) || IsA(UDynamicClass::StaticClass());
ClassAddReferencedObjectsType AddReferencedObjectsFn = nullptr;
#if !WITH_EDITOR
// In no-editor builds UObject::ARO is empty, thus only classes
// which implement their own ARO function need to have the ARO token generated.
if (ClassAddReferencedObjects != &UObject::AddReferencedObjects)
{
AddReferencedObjectsFn = ClassAddReferencedObjects;
}
#else
AddReferencedObjectsFn = ClassAddReferencedObjects;
#endif
ReferenceTokenStream.Fixup(AddReferencedObjectsFn, bKeepOuter, bKeepClass);
}
if (ReferenceTokenStream.IsEmpty())
{
return;
}
// Emit end of stream token.
static const FName EOSDebugName("EndOfStreamToken");
EmitObjectReference(0, EOSDebugName, GCRT_EndOfStream);
// Shrink reference token stream to proper size.
ReferenceTokenStream.Shrink();
check(!HasAnyClassFlags(CLASS_TokenStreamAssembled)); // recursion here is probably bad
ClassFlags |= CLASS_TokenStreamAssembled;
}
}
代码有点长,但总结一下,就是为每个被UClass修饰的类,生成对应的token stream,用于描述GC信息。同时通过 CLASS_TokenStreamAssembled
标记防止重复生成token stream。
较为详细的流程如下:
- 首先加上锁,防止另外一个线程同时进入该函数执行一样的流程,造成内存读写冲突
- 遍历UClass中的每个Property,调用每个Property的
EmitReferenceInfo()
方法,会将UClass对象的指针传入,主要是为了后续再次调用UClass的EmitObjectReference()
方法,将每个Property的内存偏移信息、类型信息传回,并存入到每个UClass对象的ReferenceTokenStream
成员中。需要说明的是,不同类型的Property的EmitReferenceInfo()
方法都被覆盖/重写(override)了,也就是说每个Property类都有自己的EmitReferenceInfo()
方法。 - 如果这个类有父类,则会递归地调用
AssembleReferenceTokenStream()
方法,收集父类的上述信息到ReferenceTokenStream
中,同时将父类的 token stream 添加到自己的 token stream 之前;该步骤会一直持续到UObjectBase
类,因为这个类没有父类。 - 最后完成收集会将
ClassFlags
添加上CLASS_TokenStreamAssembled
标志,表示该类的token stream信息已经收集完毕。
也就是说,没有被UProperty
宏修饰的成员就不会加入到GC引用链中,每次GC都会被清除掉,同时指针置为 nullptr
,当然也有其他方式避免被清除。
看到这里的时候我脑子里出现了三个疑问:
- 如果是一直递归收集父类的内存偏移信息直到
UObjectBase
类,那必然有很多重复收集的信息,很简单的例子就是类UObject
作为整个对象系统的基类,不是会被重复收集很多次吗? - 为什么需要把父类成员的token stream添加到子类之前呢?
- 在内存中,类成员的偏移信息是如何记录的(字节对齐)?
首先第一个问题,每个类的token stream是不会被重复记录的。经过继承后,各个类之间的关系,有点类似于一棵多叉树。
而递归调用 AssembleReferenceTokenStream()
函数(非静态)的过程,就是从多叉树的某个叶子结点,向整棵树的根结点回溯。每到达一个新结点,首先就会检查是否该结点是否已经被遍历过了,也就是看每UClass::ClassFlags & CLASS_TokenStreamAssembled
是不是为1,即遍历过就会直接返回,而对每个结点/每个 UClass
处理的过程是加锁的。所以每个结点都不会被重复遍历,也就是每个类的 token stream 是不会被重复记录的。
2、3问题与C++对象内存模型和字节对齐有关,以下介绍。
类成员内存信息收集
token stream与内存偏移
很好奇token stream是如何将内存偏移信息以及类型信息编码到一个int32的。
看了源码和相关的知识以后,结果还是比我想象中的要简单的,也发现 UE4 将 UProperty
替换成了 FProperty
。
FProperty
没有再从 UObject
继承,而是继承自 FField
, FField
也没有继承自 UObject
。
从函数 EmitReferenceInfo()
开始看,首先发现 FProperty::EmitReferenceInfo()
是一个空的函数。
实际上不同类型的的 EmitReferenceInfo()
是各自实现的:
我看了部分类型的实现,发现基本都是将偏移量以及各自成员的名字传回到UClass,这里以 FStructProperty::EmitReferenceInfo()
为例。
void FStructProperty::EmitReferenceInfo(UClass& OwnerClass, int32 BaseOffset, TArray<const FStructProperty*>& EncounteredStructProps)
{
check(Struct);
if (Struct->StructFlags & STRUCT_AddStructReferencedObjects)
{
UScriptStruct::ICppStructOps* CppStructOps = Struct->GetCppStructOps();
check(CppStructOps); // else should not have STRUCT_AddStructReferencedObjects
FGCReferenceFixedArrayTokenHelper FixedArrayHelper(OwnerClass, BaseOffset + GetOffset_ForGC(), ArrayDim, ElementSize, *this);
OwnerClass.EmitObjectReference(BaseOffset + GetOffset_ForGC(), GetFName(), GCRT_AddStructReferencedObjects);
void *FunctionPtr = (void*)CppStructOps->AddStructReferencedObjects();
OwnerClass.ReferenceTokenStream.EmitPointer(FunctionPtr);
}
if (ContainsObjectReference(EncounteredStructProps, EPropertyObjectReferenceType::Strong | EPropertyObjectReferenceType::Weak))
{
FGCReferenceFixedArrayTokenHelper FixedArrayHelper(OwnerClass, BaseOffset + GetOffset_ForGC(), ArrayDim, ElementSize, *this);
FProperty* Property = Struct->PropertyLink;
while( Property )
{
Property->EmitReferenceInfo(OwnerClass, BaseOffset + GetOffset_ForGC(), EncounteredStructProps);
Property = Property->PropertyLinkNext;
}
}
}
名字是在反射信息收集阶段的时候,通过生成文件写入的。这里构造了一个 FixedArrayHelper
对象,但是一直没被用到,构造函数里也会调用 UClass::EmitObjectReference()
传回内存信息,这样做的意义什么呢?。然后对 FProperty::ArrayDim
也不是很清楚,我猜测是一个 FProperty
对象可能对应多个成员,所以需要执行多次UClass::EmitObjectReference()
,或者是处理循环引用 。。。我猜的。。。
那内存偏移量呢? 这里调用了一个函数是 GetOffset_ForGC()
。
/** Return offset of property from container base. */
FORCEINLINE int32 GetOffset_ForGC() const
{
return Offset_Internal;
}
实际也只是返回了一个成员变量而已,也就是说偏移量在对应的 FProperty
对象生成的时候就就已经决定好了。应该是在收集反射信息的时候,就会填充好这个 Offset_Internal
。回顾一下文章 UE4中类型生成文件分析,发现生成文件中属性的部分,有个 STRUCT_OFFSET()
的调用:
const UE4CodeGen_Private::FUnsizedIntPropertyParams Z_Construct_UClass_UHH_Statics::NewProp_HHID = { "HHID", nullptr, (EPropertyFlags)0x0010000000000000, UE4CodeGen_Private::EPropertyGenFlags::Int, RF_Public|RF_Transient|RF_MarkAsNative, 1, STRUCT_OFFSET(UHH, HHID), METADATA_PARAMS(Z_Construct_UClass_UHH_Statics::NewProp_HHID_MetaData, UE_ARRAY_COUNT(Z_Construct_UClass_UHH_Statics::NewProp_HHID_MetaData)) };
const UE4CodeGen_Private::FPropertyParamsBase* const Z_Construct_UClass_UHH_Statics::PropPointers[] = {
(const UE4CodeGen_Private::FPropertyParamsBase*)&Z_Construct_UClass_UHH_Statics::NewProp_HHID,
};
到这里就清晰很多了,通过 STRUCT_OFFSET()
的调用可以获知某个成员在 struct/class 内的偏移量,继续看 STRUCT_OFFSET()
。
看到这里会继续调用 offsetof()
,依然是一个宏,这个宏目前在各个编译器上都得到了支持,当然开发者也可以自己实现,也就是。。。
#ifndef offsetof
#define offsetof(STRUCTURE,FIELD) ((int)((char*)&((STRUCTURE*)0)->FIELD))
#endif
这里很巧妙使用了以地址0作为对象地址,然后取成员地址的方式,这样得到的成员地址就是对应的偏移量。
不过这里还有个问题,这种方法只能用于public成员的,protect/private成员怎么解决呢?这里挖个坑给,后续写其他的文章来详细解释和分析。
起初我以为计算成员的偏移量会是一个很复杂的事情,需要考虑字节对齐以及开发者自定义的对齐之类的。不过这依然是编译器提供的功能,成员偏移本身也是编译器来决定的。
token stream记录与对象模型
在以上分析的基础上,token stream的记录过程就很明了。继续看 UClass::EmitObjectReference()
,构造了一个 FGCReferenceInfo
。
void UClass::EmitObjectReference(int32 Offset, const FName& DebugName, EGCReferenceType Kind)
{
FGCReferenceInfo ObjectReference(Kind, Offset);
ReferenceTokenStream.EmitReferenceInfo(ObjectReference, DebugName);
}
FGCReferenceInfo
会将偏移量和GC类型一起写入到一个union中,于是成员的信息就被编码到了一个uint32中,FGCReferenceInfo
本身也是4字节大小。
struct FGCReferenceInfo
{
FORCEINLINE FGCReferenceInfo( EGCReferenceType InType, uint32 InOffset )
: ReturnCount( 0 )
, Type( InType )
, Offset( InOffset )
{
check( InType != GCRT_None );
check( (InOffset & ~0x7FFFF) == 0 );
}
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 : 5; // The number of bits needs to match TFastReferenceCollector::FStackEntry::ContainerHelperType
/** Offset into struct/ object */
uint32 Offset : 19;
};
/** uint32 value of reference info, used for easy conversion to/ from uint32 for token array */
uint32 Value;
};
};
最后,将编码后的uint32存入到 FGCReferenceTokenStream::Tokens
中。同时为了方便检查和调试,也会把成员的名字存入到 FGCReferenceTokenStream::TokenDebugInfo
中,虽然 GC 整个环节实际上用不到。。。
明确token stream记录的流程后,再看为什么父类成员的token stream为什么要在子类成员之前。
估计很多人已经想到了,因为 C++ 继承时,子类对象的内存布局中就是将父类成员置为子类之前的。
目前已经有相当多的文章和资料分析了C++的对象模型,这里仅简单看下成员的情况。
对象内存中按低地址到高地址,依次是虚表指针、父类成员、子类成员。所以在 UClass::ReferenceTokenStream
的 FGCReferenceTokenStream::Tokens
中,将父类成员的token stream 放前面。
其他
- 关于GC锁
- 分析可达性之前需要加锁,主要是为了避免对象创建/销毁的时候和对象被GC时冲突,因为创建对象的整个过程并非原子,存在对象已创建还未被指针指向(引用)但已经被标记清除的可能。
- 默认的GC的调用发生在
UWorld::Tick()
中,也就是跟随游戏逻辑一起的,这算是一种取巧,避过了一些问题,也造成了一定的约束- GC不会影响到游戏线程中对所有
UObject
对象的操作,也不需要考虑这方面的读写冲突 - UE GC规定了不能在其他线程动态创建和销毁
UObject
对象 - 几乎不用考虑局部变量创建和销毁的问题,之前一直在思考如果在游戏进程某个函数中创建一个局部的
UObject
,GC线程如何考虑该局部变量的生死。。。然后现在GC直接跟游戏逻辑在一起同步执行,也就是到GC的时候,所有局部的UObject
就可以无脑全部认为不可达了。。。。 - 其他语言的GC都是通过信号/字节码hook等方式实现stop the world,且创建和销毁对象的时候都有对应的线程安全操作。UE4几乎没有这方面的考虑,也无需考虑
- 其他线程对UObject的操作需要加锁,通过
FGCScopeGuard::FGCScopeGuard()
实现,该操作会阻塞 GC 的执行(调用FGCCSyncObject::Get().LockAsync();
)。
- GC不会影响到游戏线程中对所有
- 看源码发现GC锁主要解决的是其他线程资源加载时候操作
UObject
与 GC 线程操作冲突
- token收集对性能的影响
- UE 在Editor和Player两种模式中,对于每个UObject,产生的token的个数不一样
- Editor:8 个
- Player:3 个
- 在i9 10900k上测试,50w个 UObject,开启多线程的情况下,性能差异较大:
- Editor:40+ ms
- Player:7.4 ms
- UE 在Editor和Player两种模式中,对于每个UObject,产生的token的个数不一样