Yuerer's Blog

钰儿的Blog

本文以 UE5.4 为基准,剖析反射代码的生成内容和注册流程。

简介

UE5 的 C++ 需要各种宏来辅助开发,在编译时使用 UHT 工具扫描这些宏来生成 XXX.generated.hXXX.gen.cpp 两个文件,接下来将分析UHT生成的文件,来学习反射具体做了什么。代码将会删减掉热更相关的内容,只关注核心逻辑。

反射代码

Enum

枚举的声明在C++中有三种方式,C风格、namespace、enum class。

1
2
3
4
5
6
enum class ECppForm
{
Regular,
Namespaced,
EnumClass
};

为了减小本文篇幅,此处只看 enum class。在一个新文件中定义完该 enum class 后,进行构建。

1
2
3
4
5
6
UENUM(BlueprintType)
enum class EMyEnumClass:uint8
{
Enum1 UMETA(DisplayName = "DisplayInBlueprint"),
Enum2,
};

最终会生成 generated.hgen.cpp 两个文件。

generated.h 的内容如下,仅仅只是一些模板全特化,便于开发。

1
2
3
4
5
6
7
#define FOREACH_ENUM_EMYENUMCLASS(op) \
op(EMyEnumClass::Enum1) \
op(EMyEnumClass::Enum2)

enum class EMyEnumClass : uint8;
template<> struct TIsUEnumClass<EMyEnumClass> { enum { Value = true }; };
template<> REFLECTION_API UEnum* StaticEnum<EMyEnumClass>();
阅读全文 »

本文主要剖析 UE5 网络中是如何进行属性同步和RPC的。

同步 Actor

要进行属性同步,首先就要先同步 Actor,但更要知道哪些 Actor 需要网络同步。

哪些 Actor 需要网络同步

Actor 需要设置 bReplicates 为 true,才会进行同步,

以 Spawn Pawn 为例,玩家登录之后会由 GameMode 创建 Pawn 实例。

1
2
3
4
5
6
7
8
AActor* UWorld::SpawnActor( UClass* Class, FVector const* Location, FRotator const* Rotation, const FActorSpawnParameters& SpawnParameters )
{
...
AActor* const Actor = NewObject<AActor>(LevelToSpawnIn, Class, NewActorName, ActorFlags, Template, false/*bCopyTransientsFromClassDefaults*/, nullptr/*InInstanceGraph*/, ExternalPackage);
// Add this newly spawned actor to the network actor list. Do this after PostSpawnInitialize so that actor has "finished" spawning.
AddNetworkActor( Actor );
return Actor;
}

bReplicates 为 true,则 RemoteRole 为 ROLE_SimulatedProxy,表示是远端为模拟代理。

1
2
3
4
5
void AActor::PostInitProperties()
{
Super::PostInitProperties();
RemoteRole = (bReplicates ? ROLE_SimulatedProxy : ROLE_None);
}

将需要同步的 Actor 加入到 NetDriver中的一个集合里, 至此就找到了要网络同步的 Actor,需要注意一点是 Replicate 是支持动态开关的。

阅读全文 »

本文剖析 UE5 客户端与DS建立连接后的登录流程,以及 Bunch 的发送接收,这样之后看属性同步时,能轻松一些。

登录流程

书接上回,握手之后会执行 UPendingNetGame::SendInitialJoin

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void UPendingNetGame::BeginHandshake()
{
// Kick off the connection handshake
UNetConnection* ServerConn = NetDriver->ServerConnection;
if (ServerConn->Handler.IsValid())
{
ServerConn->Handler->BeginHandshaking(
FPacketHandlerHandshakeComplete::CreateUObject(this, &UPendingNetGame::SendInitialJoin));
}
else
{
SendInitialJoin();
}
}

在继续分析之前,先来看官方的注释,描述了登录的流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Most of the work for handling these control messages are done either in UWorld::NotifyControlMessage,
and UPendingNetGame::NotifyControlMessage. Briefly, the flow looks like this:

Client's UPendingNetGame::SendInitialJoin sends NMT_Hello.

Server's UWorld::NotifyControlMessage receives NMT_Hello, sends NMT_Challenge.

Client's UPendingNetGame::NotifyControlMessage receives NMT_Challenge, and sends back data in NMT_Login.

Server's UWorld::NotifyControlMessage receives NMT_Login, verifies challenge data, and then calls AGameModeBase::PreLogin.
If PreLogin doesn't report any errors, Server calls UWorld::WelcomePlayer, which call AGameModeBase::GameWelcomePlayer,
and send NMT_Welcome with map information.

Client's UPendingNetGame::NotifyControlMessage receives NMT_Welcome, reads the map info (so it can start loading later),
and sends an NMT_NetSpeed message with the configured Net Speed of the client.

Server's UWorld::NotifyControlMessage receives NMT_NetSpeed, and adjusts the connections Net Speed appropriately.
阅读全文 »

本文主要剖析 UE5 中客户端是如何与DS建立连接,构建基础 Channel,以及无状态握手流程。

连接建立

服务端监听流程

UGameInstance::EnableListenServer 调用 UWorld::Listen 去创建 UNetDriver 对象。

UNetDriver 默认有两个子类, IpNetDriver 和 DemoNetDriver 后面一个用于回放。

根据要创建的 NetDriver 名, 查找并构造该 Driver, 并存入 World->Context.ActiveNetDrivers,这里面很多地方会用到,比如需要设置某个 Actor 冻结时,就需要通知各个 NetDriver。

1
2
3
4
5
6
7
8
UNetDriver* CreateNetDriver_Local(UEngine* Engine, FWorldContext& Context, FName NetDriverDefinition, FName InNetDriverName)
{
Definition = Engine->NetDriverDefinitions.FindByPredicate(FindNetDriverDefPred);
UClass* NetDriverClass = StaticLoadClass(UNetDriver::StaticClass(), nullptr, *Definition->DriverClassName.ToString(), nullptr, LOAD_Quiet);
ReturnVal = NewObject<UNetDriver>(GetTransientPackage(), NetDriverClass);
// 数组 重载了 operator new
new(Context.ActiveNetDrivers) FNamedNetDriver(ReturnVal, Definition);
}
阅读全文 »

本文主要剖析 UE5 中的可靠UDP的设计思路。

简介

UE5的网络收发使用UDP进行通信,而UDP又是不可靠的协议,只管发出,不管对端是否收到,也不保序,因此需要有一套机制来使得UE5的数据包有保序、可靠这两大特点。

Packet

UE5在UDP之上,包装了一层 Packet,其内部传输的数据是一个个 Bunch,可靠不可靠指的是 Bunch的属性,Bunch 是什么这个暂时可以先不用关心,但 Packet 是需要搞明白的,因为 UE5 是使用 Packet 来完成保序的工作。

序列号

既然要实现保序、可靠,就需要知道对端收没收到包,那么自然是要通知对端我发的消息ID,以及我收到的消息ID,UE5也不例外,Packet 头部包含了 Seq 和 AckedSeq(取得最新收到的Packet的序列号),为了避免序列号回环无法直接比较序列号大小的情况,以及序列号占用比特位过大的问题,Seq 使用TSequenceNumber 实现,容量为14bit,可表示 [0, 16383],两个 Seq 的差值若小于最大值的一半,则认为比较是正确的,没有发生回环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
template <SIZE_T NumBits, typename SequenceType>
class TSequenceNumber
{
static_assert(TIsSigned<SequenceType>::Value == false, "The base type for sequence numbers must be unsigned");

public:
using SequenceT = SequenceType;
using DifferenceT = int32;

// Constants
enum { SeqNumberBits = NumBits };
enum { SeqNumberCount = SequenceT(1) << NumBits };
enum { SeqNumberHalf = SequenceT(1) << (NumBits - 1) };
enum { SeqNumberMax = SeqNumberCount - 1u };
enum { SeqNumberMask = SeqNumberMax };
};
阅读全文 »

近期在改 Lua 5.4 的垃圾回收,虽然之前也写过分代垃圾回收的原理,但这次改完之后对其更有感悟,就简单记录一下Lua 5.4 的分代垃圾回收的实现原理。

简介

分代垃圾回收认为对象分为年轻代和老年代,其中年轻代对象很快就会被释放(比如临时对象),而老年代对象存在的时间比较长,不容易被释放,因此也就不需要经常去扫描老年代,只需要经常去扫描年轻代,等到年轻代垃圾回收的时候实在收不回对象,再进行一次全量垃圾回收。

原理

Lua 的 age 总共占用 3 Bit,刚创建出来的对象为 G_NEW ,当它活过一轮垃圾回收后,提升为 G_SURVIVAL ,若再活过一轮垃圾回收,则彻底进入 G_OLD 老年代,不在年轻代中扫描它。

1
2
3
4
5
6
7
8
#define G_NEW		0	/* created in current cycle */
#define G_SURVIVAL 1 /* created in previous cycle */
#define G_OLD0 2 /* marked old by frw. barrier in this cycle */
#define G_OLD1 3 /* first full cycle as old */
#define G_OLD 4 /* really old object (not to be visited) */
#define G_TOUCHED1 5 /* old object touched this cycle */
#define G_TOUCHED2 6 /* old object touched in previous cycle */
#define AGEBITS 7 /* all age bits (111) */

这里面的 G_OLD0 是用于 Barrier forward,假设你创建了一个新对象,它本该是 G_NEW 但因为它被老年代对象引用,所以必须要强行将它改为老年代,否则会发生跨代引用,该新对象直接被清理掉。

同理 G_TOUCHED1 则是用于 Barrier back,假设你创建了一个新对象,然后放置在一个老年代的 table中,此时为了不频繁触发该 table 的 barrier,则将其修改为 G_TOUCHED1 ,同时将其放置在 grayagain 链表中,这是因为老年代table是不会在年轻代的垃圾回收中被扫描到,但此时老年代又确实引用了年轻代对象,所以要将它放在一条特殊链表中,使其能在年轻代中被扫描到。

阅读全文 »

前言

Lua 开发者通常听说或使用过 LuaJIT,但是可能因为种种原因未能理解其工作原理,在这里分享一篇 Jakob Erlandsson 和 Simon Kärrman 的硕士毕业论文,TigerShrimp: An Understandable Tracing JIT Compiler,该论文讲述了如何为 JVM 开发一个 Tracing JIT,并附带了源码以及可视化工具。下文将简要剖析一些其实现原理。

编译流程

TigerShrimp 基于 JVM Bytecode,使用 Javac 将 Java 代码文件编译为 .class 文件,后直接进行 decode .class 文件,通过这种方式绕过 Parser 阶段,得到 bytecode

执行流程

TigerShrimp 内部有个简单的 Interpreter,用以直接执行 bytecode,执行每一条 Instruction时,会记录当前的 pc (二元组,记录函数索引和指令索引,不然指令索引可能重复),是否为热路径,若为热路径,则会执行 record 流程,记录每一条执行的指令。(通常记录循环,循环有回边,记录执行次数,执行次数大于一阈值,则认为是热路径)。

若已经有 native code,即已经是热路径并完成了生成机器码的工作,则直接执行 native code

阅读全文 »

前言

定时器的实现通常使用有序数据结构来实现,一般通过红黑树、跳表、最小堆、时间轮来实现。

其中又以最小堆最容易实现,红黑树最难实现。

Skynet 选择时间轮的原因估计是多线程,时间轮的插入平均复杂度比其他几个都要低,非常适用于多线程场景。

本篇就简单剖析一下 Skynet 实现的 TimingWheel。以下代码为方便阅读有删减。

时间轮

首先实现上是采用数组 + 链表的形式进行实现。

先定义了一个链表,存放了过期时间,从 *tail 可以看出,此结构为尾插法,毕竟后插入的定时器后执行,很合理。

1
2
3
4
5
6
7
8
9
struct timer_node {
struct timer_node *next;
uint32_t expire;
};

struct link_list {
struct timer_node head;
struct timer_node *tail;
};
阅读全文 »

背景

Lua 项目中,通常需要工具进行内存监控,目前开源的工具中有 lua-snapshot,但这个工具的缺陷是开销比较大,在调用接口之后,会扫描整个GC链表,找出所有的GC对象,并进行统计,最后会创建大量的 Lua 对象,将结果存在里面,这就会导致本身内存已经够高了,再用这个工具的话,很可能会触发 OOM 或者是 STW,业务无法正常提供服务。

作为补充,期望有个工具能够监控所有的对象开辟的位置和大小信息,进行精确定位代码问题。

最终实现的效果如下图所示:

阅读全文 »

剖析一下 CPython 的自动垃圾回收机制,并尝试提出改进的思路。

引用计数

相信有过计算机基础的人,哪怕对垃圾回收不那么熟悉,也肯定知道引用计数这个玩意。引用计数诞生于上个世纪,其主要思想是通过给每个对象增加计数,当计数为0时,则肯定没人使用该对象,可以放心将其删除。

虽然这个方法看起来有点糙,但在实际项目中,它的优点在于可以更实时的释放内存,释放内存的时机更精确,这也是为什么有的项目会尝试给 Lua 增添一个引用计数的垃圾回收,避免内存上涨过快。

凡事都有利弊,它的缺点也很明显,无法处理循环引用。

以下用 Python 举一个非常普遍的例子。

1
2
3
4
5
6
7
8
9
10
11
12
class A:  
pass

class B:
pass

a = A()
b = B()
a.b = b
b.a = a
del a
del b

在上面中,我们手动删除了 ab ,理应进行释放,但由于 ab 互相构成了循环引用,导致其引用计数总是不为0,进而造成内存泄漏,而 CPython 对其解决方法也极其简单,就是将所有可能造成循环引用的对象,构成一个双向链表进行扫描,从 root object 出发进行扫描 - 清除,无法到达的对象就是可释放的对象,普通的对象直接采用引用计数去释放,简单快捷。

怎么去验证以上结论呢?我们可以用反证法,当 del adel b 后,再调用 gc.collect() 查看其是否能被回收到,如果能回收到,说明在此时引用计数已经失效。

1
2
3
4
5
6
7
8
# 设置 debug 标签,使得垃圾回收后的对象 存放至 gc.garbage 列表中
gc.set_debug(gc.DEBUG_SAVEALL)

# 回收第0代垃圾对象
gc.collect(0)

# 打印出回收的垃圾对象
print(gc.garbage)

可以看出引用计数确实失效了,因为通过 扫描-清除 回收能回收到这两个对象。

阅读全文 »
0%