UE 补完计划(二) GC-3
参考:
前言
标记过程共分为两个阶段:标记对象与可达性分析。对应到具体的函数就是 MarkObjectsAsUnreachable()
与 PerformReachabilityAnalysisOnObjectsInternal()
,上一节我们分析了前者,本节就来分析第二个函数。
PerformReachabilityAnalysis()
的执行逻辑
在分析该函数之前需要再理一理逻辑。
不知道大家还记不记得,CollectGarbageInternal()
最先调用的就是 PerformReachabilityAnalysis()
来执行 “标记-清扫” 中的标记阶段。我们先回顾一下这部分的逻辑:
1 | /** |
首先,申请了一个名为 ArrayStruct
的结构体,该结构体就是用于存放所有可达的对象的;接下来,在标记对象的阶段,将 ArrayStruct->ObjectsToSerialize
作为引用传递给了 MarkObjectsAsUnreachable()
函数,对应这一句:
1 | (this->*MarkObjectsFunctions[GetGCFunctionIndex(OptionsForMarkPhase)])(ObjectsToSerialize, KeepFlags); |
在标记对象阶段执行完成后,ObjectsToSerialize
就存放了所有的可达对象信息,由于 ObjectsToSerialize
是 ArrayStruct
的成员变量,所以信息也等同于存放在 ArrayStruct
中。
接下来就进入了可达性分析阶段:
1 | PerformReachabilityAnalysisOnObjects(ArrayStruct, InOptions); |
PerformReachabilityAnalysisOnObjects()
只是一层包装,会将 ArrayStruct
等参数转发给 ReachabilityAnalysisFunctions
也即 PerformReachabilityAnalysisOnObjectsInternal()
。
PerformReachabilityAnalysisOnObjectsInternal()
该函数也只是做了一些参数转发的工作。具体而言,会先构造一个 TFastReferenceCollector
类型的对象,再调用该对象的 CollectReferences()
成员函数。 同时,会将 ArrayStruct
转发给该函数。
1 | template <EFastReferenceCollectorOptions CollectorOptions> |
我们先不考虑 TFastReferenceCollector
、FGCReferenceProcessor
与 FGCCollector
之间的关系,先从函数的作用开始分析起。
该函数也分了多线程与单线程两套处理方式,而它们最终都会调用一个名为 ProcessObjectArray()
的函数来完成后续的工作。
1 | /** |
TokenStream
在具体分析 ProcessObjectArray()
之前,还需要了解 TokenStream
这个概念。FGCReferenceTokenStream
是一个包含了多个 FGCReferenceInfo
的数组。GCReferenceInfo
定义在 GarbageCollection.h
中,是一个用于辅助 GC 分析对象的引用的数据结构。内部包含了一些重要属性,如返回深度,引用类型,偏移量等。
1 | /** |
Token
,准确的说是 ReferenceToken
,翻译为引用记号流。引擎在启动时,会把代码中的各种类的类型信息收集好(也就是通常说的“反射”)。类型信息包括很多东西:比如说这个类里有什么变量,有什么函数,这个类的尺寸大小,是否是动态类等等。那哪个类型信息会在垃圾回收中用到呢?回想一下,UE 垃圾回收的原理:从已有的对象出发,查询这些对象分别引用了什么对象,从而构建出一个引用树(也可以称之为“可达的对象树”)。既然是引用别的对象,那么答案就很显然了,是使用了类型信息里的 “这个类有哪些变量” 这一信息。
如果在进行可达性分析的时候,去查询这个对象保存了对哪些变量的引用,固然可以。但是,既然我们只需要用到“这个类有哪些变量”这一信息,那么去访问一整个UObject
,自然就需要为我们用不到的信息而支付更多的访问代价。
垃圾回收作为 UE 的一个底层系统,每时每刻都在运转的系统,它的效率提升将会直接给游戏的性能带来巨大的影响。因此 UE 在这里非常的“抠门”——它要用什么信息,就坚决只查询什么信息。于是 ReferenceToken
就应运而生了。早在引擎启动的时候,就会为每一个类都生成一份引用记号流(ReferenceToken
),里面保存了这个类有哪些变量,因此在进行可达性分析的时候,只需要对ReferenceToken
进行分析就可以了。
Token
和一个完整的UObject
,体积相差了十倍有余。它被UE用奇技淫巧组装成了一个4字节的结构。
1 | /** Mapping to exactly one uint32 */ |
这部分内容与 AssembleReferenceTokenStream()
函数展开了说也十分复杂,这里我们只需要这样简单理解:ReferenceToken
是 UE 为了减少访问 UObject
的开销设计出的一种结构,包含了类中某个属性的引用类型、偏移量等信息。而 TokenStream
则是一个类中所有纳入 GC 系统的属性的 ReferenceToken
的集合,可以通过 TokenStream
中记录的偏移量轻松地访问到某个对象的指定属性,而不用访问 UObject
自身。
ProcessObjectArray()
好,现在压力给到 ProcessObjectArray()
,还是先看一下函数注释与签名:
1 | /** |
可以看出该函数的作用就是遍历现有的 UObject
的 tokenstream
以找到更多的引用。已知在 MarkObjectsAsUnreachable()
执行完后,我们已经将所有纳入 GC 系统的对象都标记了可达/不可达,那为什么还要继续进行遍历呢?
举个例子,比如现在场景中有一个类型为 A
的对象 a
,该对象继承了 UObject
因此会被纳入 GC 系统,在标记的第一阶段,也就是标记对象阶段,会对该对象进行标记,以判断是否需要被 GC。而如果 A
类型的对象中还引用着一个 B
类型的对象 b
呢?如果仅有 a
在引用 b
,那么如果只回收了 a
的内存,b
岂不是就当场内存泄漏了。
因此,还需要从已有对象出发,判断更多的对象是否可达,并加入到列表中。这就是 ProcessObjectArray()
要完成的任务了。
我们可以结合函数的具体逻辑进行更清晰的理解,在上一篇博客 对于 MarkObjectsAsUnreachable()
函数的分析中可以看出,该函数对于对象的标记十分直接,仅仅判断了是否为根节点及对象是否携带特定标签的情况。因此,即使该函数也是遍历了一遍 GUObjectArray
但对于对象的标记是不全面的,有许多上述提到的被引用的情况不能被正确标记,因此才需要用到 PerformReachabilityAnalysis()
来具体分析上述的这些情况。
此外,从注释中我们可以知道,进行可达性分析时实际上是对 UObject
的 Token
进行分析。从上面的分析中我们知道,Token
是由 UPROPERTY
提供的信息而生成的一组精简的信息,指出了这个对象引用了哪些其它对象。通过分析 Token
而不是直接分析 UObject
本身,可以大大提高效率。
进入函数逻辑。
首先,定义了两个数组:
1 | /** Growing array of objects that require serialization */ |
其中,InObjectsToSerializeStruct
也即传入的 ArrayStruct
,内部存放的是标记的第一阶段找到的所有可达对象。NewObjectsToSerialize
是用于存储在可达性分析循环中找到的更多的 要拿来垃圾回收的UObject
,这两个数组会在循环结束后交换。并清空掉NewObjectsToSerialize
。因此最终所有信息还是会回到 ObjectsToSerialize
。
1 | else if (NewObjectsToSerialize.Num()) |
函数会通过一个 while
循环来遍历 ObjectsToSerialize
中的元素。
首先会检查是否组装好了 TokenStream
,否的话会重新调用 AssembleReferenceTokenStream()
进行 TokenStream
的组装,TokenStream
的作用在上文中已经分析过,是用于快速访问一个对象引用的其他对象的。
1 | // Make sure that token stream has been assembled at this point as the below code relies on it. |
在获取到 TokenStream
后会对该对象的 TokenStream
进行解析,以拿到 ReferenceInfo
等信息。
1 | const FGCReferenceInfo ReferenceInfo = TokenStream->AccessReferenceInfo(ReferenceTokenStreamIndex); |
随后会根据 ReferenceInfo.Type
的不同来分别处理,如 Object
、ArrayObject
、ArrayStruct
等。ReferenceInfo.Type
是一个 EGCReferenceType
枚举类型的变量,该枚举类型也定义在 GarbageCollection.h
中:
1 | /** |
这里以 GCRT_Object
为例简单分析一下:
1 | switch(ReferenceInfo.Type) |
可以看出,这里的确是利用偏移量来直接获取到了一个对象引用的另一个对象,并且使用 ConditionalHandleTokenStreamObjectReference()
来对这个新找到的对象继续进行可达性标记。
1 | FORCEINLINE void ConditionalHandleTokenStreamObjectReference(FGCArrayStruct& ObjectsToSerializeStruct, UObject* ReferencingObject, UObject*& Object, const int32 TokenIndex, const EGCTokenType TokenType, bool bAllowReferenceElimination) |
ConditionalHandleTokenStreamObjectReference()
会将参数转发给 ReferenceProcessor.HandleTokenStreamObjectReference()
。中间又会经历很多次转发,最终会转发给 HandleObjectReference()
来进行真正的可达性分析。
HandleObjectReference()
1 | /** |
从函数的签名可以看出,ReferencingObject
代表引用者(也就是 ObjectsToSerialize
中本身就有的对象),Object
是被引用者,就是类似于 ReferencingObject
的成员变量等的变量,该函数的作用就是将可达的 Object
放入 ObjectsToSerializeStruct
中。
函数内部的处理分为多种情况:
1 | const int32 ObjectIndex = GUObjectArray.ObjectToIndex(Object); |
这里的 ObjectItem
就是通过 GUObjectArray
及下标获取到的真正的被引用的对象。
-
首先,如果被引用的对象已经被标识为
Garbage
,在bAllowReferenceElimination == true
时会直接将其替换为空指针。此外还会根据其他配置做进一步的处理。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// Check for garbage objects but skip objects that were already marked as persistent garbage
if (!IsWithPendingKill() && ObjectItem->HasAnyFlags(EInternalObjectFlags::Garbage))
{
if (bAllowReferenceElimination)
{
// To avoid content changes, allow reference elimination inside of Blueprints
if (ReferencingObject && TokenType == EGCTokenType::NonNative)
{
Object = nullptr;
return;
}
if (bGarbageReferenceTrackingEnabled && !ObjectItem->HasAnyFlags(EInternalObjectFlags::PersistentGarbage))
{
HandleGarbageReference(ObjectsToSerializeStruct, ReferencingObject, Object, TokenIndex);
}
}
else if (bGarbageReferenceTrackingEnabled)
{
// This object is being referenced by a persistent reference which means it wouldn't have been GC'd anyway
// so no need to track this object
ObjectItem->ThisThreadAtomicallySetFlag(EInternalObjectFlags::PersistentGarbage);
}
} -
如果
ObjectItem
已经被标记为IsPendingKill()
,则也会被清空。1
2
3
4
5
6
7
8// Remove references to pending kill objects if we're allowed to do so.
if (IsWithPendingKill() && ObjectItem->IsPendingKill() && bAllowReferenceElimination)
{
checkSlow(ObjectItem->GetOwnerIndex() <= 0);
// Null out reference.
Object = nullptr;
} -
如果我们引用的
Object
被标记为不可达,那么这里需要将其变为可达。如果引用的
Object
是一个非ClusterRoot
对象,那么把它加入到ObjectsToSerialize
中,后续会递归处理他。如果是一个ClusterRoot
对象,则需要把该Cluster
的所有对象全部变为可达。这里也分了单/多线程,但核心逻辑都是一样的,所以只贴出了多线程情况的代码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31// Add encountered object reference to list of to be serialized objects if it hasn't already been added.
else if (ObjectItem->IsUnreachable())
{
if (IsParallel())
{
// Mark it as reachable.
if (ObjectItem->ThisThreadAtomicallyClearedRFUnreachable())
{
// ...
// Objects that are part of a GC cluster should never have the unreachable flag set!
checkSlow(ObjectItem->GetOwnerIndex() <= 0);
if (!IsWithClusters() || !ObjectItem->HasAnyFlags(EInternalObjectFlags::ClusterRoot))
{
// Add it to the list of objects to serialize.
ObjectsToSerialize.Add(Object);
}
else
{
// This is a cluster root reference so mark all referenced clusters as reachable
MarkReferencedClustersAsReachable(ObjectItem->GetClusterIndex(), ObjectsToSerialize);
}
}
}
else
{
// ...
}
} -
如果我们引用的
Object
是Cluster
的普通成员,说明该Cluster
需要全部被标记为可达。这里先把该
Cluster
的ClusterRoot
标记为可达,之后再根据ClusterRoot
将该Cluster
全部标记为可达。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33else if (IsWithClusters() && (ObjectItem->GetOwnerIndex() > 0 && !ObjectItem->HasAnyFlags(EInternalObjectFlags::ReachableInCluster)))
{
bool bNeedsDoing = true;
if (IsParallel())
{
bNeedsDoing = ObjectItem->ThisThreadAtomicallySetFlag(EInternalObjectFlags::ReachableInCluster);
}
else
{
ObjectItem->SetFlags(EInternalObjectFlags::ReachableInCluster);
}
if (bNeedsDoing)
{
// Make sure cluster root object is reachable too
const int32 OwnerIndex = ObjectItem->GetOwnerIndex();
FUObjectItem* RootObjectItem = GUObjectArray.IndexToObjectUnsafeForGC(OwnerIndex);
checkSlow(RootObjectItem->HasAnyFlags(EInternalObjectFlags::ClusterRoot));
if (IsParallel())
{
if (RootObjectItem->ThisThreadAtomicallyClearedRFUnreachable())
{
// Make sure all referenced clusters are marked as reachable too
MarkReferencedClustersAsReachable(RootObjectItem->GetClusterIndex(), ObjectsToSerialize);
}
}
else if (RootObjectItem->IsUnreachable())
{
RootObjectItem->ClearFlags(EInternalObjectFlags::Unreachable);
// Make sure all referenced clusters are marked as reachable too
MarkReferencedClustersAsReachable(RootObjectItem->GetClusterIndex(), ObjectsToSerialize);
}
}
}
到此为止,HandleObjectReference()
也就分析完毕了。
小结
本节介绍了标记阶段的第二个函数:PerformReachabilityAnalysisOnObjectsInternal()
,该函数的作用为从已有的可达对象出发,判断更多的对象是否可达,当这个函数执行完后,也就意味着 “标记-清扫” 的 “标记” 部分已经完成,接下来就是清扫的工作了。