UE4 GC机制解析(一):GC信息收集

收集对象,构造有向图

Posted by bbkgl on June 8, 2021

微微风簇浪

散作满河星

标记-清除的GC机制要包括以下四个流程。

image-20210609115045747

接下来介绍UE4中的信息收集过程。

引擎加载流程中收集信息

由于内存信息,包括类型信息在编译期就已经确定,所以在反射支持的情况下,便能够在初始化的时候进行收集。之前介绍反射时,有讲到引擎加载的流程中会调用 ProcessNewlyLoadedUObjects() 函数(多次)。

image-20210609145043966

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

函数的执行流程如下:

  1. 遍历所有的UClass(每一个用 UCLASS() 宏修饰的类都会生成一个UClass对象)
  2. 对每个UClass生成一个默认的对象(指被UClass修饰的类的对象)
  3. 对每个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。

较为详细的流程如下:

  1. 首先加上锁,防止另外一个线程同时进入该函数执行一样的流程,造成内存读写冲突
  2. 遍历UClass中的每个Property,调用每个Property的 EmitReferenceInfo() 方法,会将UClass对象的指针传入,主要是为了后续再次调用UClass的 EmitObjectReference() 方法,将每个Property的内存偏移信息、类型信息传回,并存入到每个UClass对象的 ReferenceTokenStream 成员中。需要说明的是,不同类型的Property的 EmitReferenceInfo() 方法都被覆盖/重写(override)了,也就是说每个Property类都有自己的 EmitReferenceInfo() 方法。
  3. 如果这个类有父类,则会递归地调用AssembleReferenceTokenStream() 方法,收集父类的上述信息到 ReferenceTokenStream 中,同时将父类的 token stream 添加到自己的 token stream 之前;该步骤会一直持续到 UObjectBase 类,因为这个类没有父类。
  4. 最后完成收集会将 ClassFlags 添加上 CLASS_TokenStreamAssembled 标志,表示该类的token stream信息已经收集完毕。

也就是说,没有被UProperty宏修饰的成员就不会加入到GC引用链中,每次GC都会被清除掉,同时指针置为 nullptr,当然也有其他方式避免被清除。

看到这里的时候我脑子里出现了三个疑问:

  1. 如果是一直递归收集父类的内存偏移信息直到 UObjectBase类,那必然有很多重复收集的信息,很简单的例子就是类 UObject 作为整个对象系统的基类,不是会被重复收集很多次吗?
  2. 为什么需要把父类成员的token stream添加到子类之前呢?
  3. 在内存中,类成员的偏移信息是如何记录的(字节对齐)?

首先第一个问题,每个类的token stream是不会被重复记录的。经过继承后,各个类之间的关系,有点类似于一棵多叉树。

而递归调用 AssembleReferenceTokenStream() 函数(非静态)的过程,就是从多叉树的某个叶子结点,向整棵树的根结点回溯。每到达一个新结点,首先就会检查是否该结点是否已经被遍历过了,也就是看每UClass::ClassFlags & CLASS_TokenStreamAssembled 是不是为1,即遍历过就会直接返回,而对每个结点/每个 UClass 处理的过程是加锁的。所以每个结点都不会被重复遍历,也就是每个类的 token stream 是不会被重复记录的。

2、3问题与C++对象内存模型和字节对齐有关,以下介绍。

类成员内存信息收集

token stream与内存偏移

很好奇token stream是如何将内存偏移信息以及类型信息编码到一个int32的。

看了源码和相关的知识以后,结果还是比我想象中的要简单的,也发现 UE4 将 UProperty 替换成了 FProperty

FProperty 没有再从 UObject 继承,而是继承自 FFieldFField 也没有继承自 UObject

image-20210708205337443

image-20210708205347083

从函数 EmitReferenceInfo() 开始看,首先发现 FProperty::EmitReferenceInfo() 是一个空的函数。

image-20210708204015368

实际上不同类型的的 EmitReferenceInfo() 是各自实现的:

image-20210708204116060

我看了部分类型的实现,发现基本都是将偏移量以及各自成员的名字传回到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()

image-20210709104215627

看到这里会继续调用 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++的对象模型,这里仅简单看下成员的情况。

image-20210709162207728

对象内存中按低地址到高地址,依次是虚表指针、父类成员、子类成员。所以在 UClass::ReferenceTokenStreamFGCReferenceTokenStream::Tokens 中,将父类成员的token stream 放前面。

其他

  1. 关于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锁主要解决的是其他线程资源加载时候操作UObject 与 GC 线程操作冲突
  2. token收集对性能的影响
    • UE 在Editor和Player两种模式中,对于每个UObject,产生的token的个数不一样
      • Editor:8 个
      • Player:3 个
    • 在i9 10900k上测试,50w个 UObject,开启多线程的情况下,性能差异较大:
      • Editor:40+ ms
      • Player:7.4 ms