本文主要剖析 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 | template <SIZE_T NumBits, typename SequenceType> |
历史序列号
为了增加吞吐量,不能每次都发一个收一个,会需要一次多发几个包的情况,但是发的包多了,光靠最新收到的包的序列号,无法知道在此之前丢了哪些包,因此需要加入一个历史窗口,来存储在这段期间内,哪些包收到了,哪些包未收到。UE5 中使用 TSequenceHistory
来实现,窗口最大为 256,每个比特分别表示每个序列号的状态 。该窗口区间为 [InAckSeqAck, InAckSeq]
, InAckSeqAck
为最新收到的序列号且对端也明确知道收到的序列号,InAckSeq
为最新收到的序列号且明确知道对端不知道它已收到。这个窗口最大虽然是 256个序列号,但 InAckSeq - InAckSeqAck 可能用不了 256 这么大的窗口,因此在 UE5 中该窗口被实现为可变长的数组,既然是可变长数组,那也就需要在 Packer 头部增加该窗口的大小的信息,HistoryWordCount
就起到这一个作用。
需要注意的是当接受者突然丢失大量包时,这个历史记录可能会溢出,溢出后,需要重置 InAckSeqAck
,这段逻辑会放到下面讲,这里先跳过。
理解了以上内容,就可以开始关心 Packer Header 的组织形式。
1 | class FNetPacketNotify |
根据以上代码可知
Packet Header
1 | Seq:14 | AckSeq:14 | HistoryCount:4 |
Packet除了 Packet Header之外,还可能存在 PacketInfoPayload,这里面主要是存储了服务器的帧时长和两次发包的时间间隔,前者用于更精确的统计延迟,此处可以忽略,和主题没什么关系。
PacketInfo
1 | bHasPacketInfoPayload: 1 |
在这之后则是一个个 Bunch,也可以没有 Bunch。
最后是 1bit的结束位。
总的 Packet 结构大致如下:
1 | Seq:14 | AckSeq:14 | HistoryCount:4 |
保序
有了 Packet,就可以实现保序的功能,正如上面所述,每个 Packet 都有个序列号,当收到的序列号等于上次收到的序列号+1,则认为是有序,可以直接处理,但也有可能收到较新的包,UE5的做法是选择缓存一下,看看能不能收到前面的包,组成一个有序的情况。
最大容忍的丢包默认是3个,可以通过以下参数进行调整。
1 | static TAutoConsoleVariable<int32> CVarNetPacketOrderMaxMissingPackets(TEXT("net.PacketOrderMaxMissingPackets"), 3, |
若收到的包是预期的包的序列号+3,则默认认为前面的包丢失,并处理当前的包。以下代码中的 PacketSequenceDelta
就是两个序列号的差值。
PacketOrderCache
就是缓存区,以循环队列形式构建,其默认值为32,同样的,可以通过以下参数进行调整。
1 | static TAutoConsoleVariable<int32> CVarNetPacketOrderMaxCachedPackets(TEXT("net.PacketOrderMaxCachedPackets"), 32, |
1 | void UNetConnection::ReceivedPacket( FBitReader& Reader, bool bIsReinjectedPacket, bool bDispatchPacket ) |
需要特别注意的是,缓存区并不是无限等待的,在 Driver.TickDispatch
之后会调用 void UNetConnection::FlushPacketOrderCache(bool bFlushWholeCache*/*=false*/*)
强制将 PacketOrderCache 清空,并应用这些数据包。
GetSequenceDelta
的前提是需要(对方的序列号 > 最新收到的序列号)且(对方确认收到的序列号≥ 最新发出且确认收到的序列号)且(最新序列号(还未使用过的序列号) > 对方确认收到的序列号),才认为这个 Packet是有效的。
1 | SequenceNumberT::DifferenceT GetSequenceDelta(const FNotificationHeader& NotificationData) |
假设我们收到了正确的包,则需要根据序列号处理 ack、nak。
1 | void UNetConnection::ReceivedPacket( FBitReader& Reader, bool bIsReinjectedPacket, bool bDispatchPacket ) |
重点在 PacketNotify.Update 中。
1 | template<class Functor> |
根据对方的历史序列号窗口,决定哪些序列号是对方收到的,哪些是未收到的。
1 | template<class Functor> |
具体 ReceivedAck
和 ReceivedNak
的内容留到下一篇属性同步时讲解,读者暂时只需要知道,若为未收到该 Packet 且传输的 Bunch 是可靠的,是 reliable 的,则会重新找出该 Bunch 进行重发,通过这种方式实现了可靠传输。若为属性同步,属性同步本身就是非可靠的,丢了就丢了,但会在属性位上记录该属性没能成功同步,等待下次属性同步时一起带出最新的数据。
InternalUpdate
则是用于处理历史序列号窗口溢出的问题,接收端突然丢失大量的包时,[InAckSeqAck, InAckSeq]
区间就有概率超过256,出现ack窗口放不下的情况,进入 WaitForSequenceHistoryFlush
状态,若在这个包之前没有未确认的包,则可以直接重置 InAckSeqAck
为当前收到的包序列号 -1; 若有未确认的包,则直接通知对方整个序列号历史全丢失。
1 | FNetPacketNotify::SequenceNumberT::DifferenceT FNetPacketNotify::InternalUpdate(const FNotificationHeader& NotificationData, SequenceNumberT::DifferenceT InSeqDelta) |
WaitForSequenceHistoryFlush
状态直到以下条件才会解除。 OutAckSeq
表示最新发出且被对端确认收到了的序列号。
1 | template<class Functor> |
本文逻辑堆栈
- Packet 发送
1 | UNetConnection::SendRawBunch() |
- Packet 接收
1 | UNetConnection::ReceivedRawPacket() |