参考:

  1. https://zhuanlan.zhihu.com/p/580460488
  2. https://blog.csdn.net/u012999985/article/details/117236770
  3. https://zhuanlan.zhihu.com/p/430799766
  4. https://blog.csdn.net/csdnsevenn/article/details/106865876
  5. https://zhuanlan.zhihu.com/p/606905438

预备知识

UDP & TCP

TCP (Transmission Control Protocol)和 UDP(User Datagram Protocol) 协议属于传输层协议。

其中TCP提供IP环境下的数据可靠传输,它提供的服务包括数据流传送、可靠性、有效流控、全双工操作和多路复用。通过面向连接、端到端和可靠的数据包发送。通俗说,它是事先为所发送的数据开辟出连接好的通道,然后再进行数据发送;而UDP则不为IP提供可靠性、 流控或差错恢复功能。

一般来说,TCP对应的是可靠性要求高的应用,而UDP对应的则是可靠性要求低、传输经济的应用。

TCP支持的应用协议主要有:Telnet、FTP、SMTP等;
UDP支持的应用层协议主要有:NFS(网络文件系统)、SNMP(简单网络管理协议)、DNS(主域名称系统)、TFTP(通用文件传输协议)等。

TCP/IP 协议与低层的数据链路层和物理层无关,这也是 TCP/IP 的重要特点

UDP

UDP 是 User Datagram Protocol 的简称, 中文名是用户数据报协议,是OSI(Open System Interconnection,开放式系统互联) 参考模型中一种无连接的传输层协议,传输可靠性没有保证

UDP首部只有8字节(源端口号,目的端口号,UDP总长度,检验和,各16位/2字节)。

image.png

UDP头部的标识如下:

  • 16位源端口号:源主机的应用程序使用的端口号。
  • 16位目的端口号:目的主机的应用程序使用的端口号。
  • 16位UDP长度:是指UDP头部和UDP数据的字节长度。因为UDP头部长度为8字节,所以该字段的最小值为8。
  • 16位UDP校验和:该字段提供了与TCP校验字段同样的功能;该字段是可选的。

UDP 是面向报文传输的协议,对于应用层交下来的报文段不进行拆分合并,直接保留原有报文段的边界然后添加 UDP 的首部就交付给网络层。不论报文的长短,UDP 都不会进行处理。因此为了避免报文段过短降低传输效率以及报文段过长导致网络层对 IP 数据进行分片操作,应用层应该选择合适长度的报文交付给运输层的 UDP。

UDP是无连接的协议,数据传输之前不需要建立数据连接,也没有超时重传等机制,拥塞控制以及流量控制,但是传输速度快,灵活。

TCP

TCP(Transmission Control Protocol 传输控制协议)是一种面向连接(连接导向)的、可靠的、 基于IP的传输层协议。

TCP 的特点

  1. TCP 是面向连接的运输层协议。应用程序在使用 TCP 协议之前,必须先建立 TCP 连接。在传送数据完毕后,必须释放已经建立的 TCP 连接。
  2. 每一条 TCP 连接只能有两个端点,每一条 TCP 连接只能是点对点的(一对一)
  3. TCP 提供可靠交付的服务。通过 TCP 连接传送的数据,无差错、不丢失、不重复,并且按序到达
  4. TCP 提供全双工通信。TCP 允许通信双方的应用进程在任何时候都能发送数据。TCP 连接的两端都设有发送缓存和接受缓存,用来临时存放双向通信的数据
  5. 面向字节流。TCP 中的“流”指的是流入到进程或从进程流出的字节序列

“面向字节流”的含义是:虽然应用程序和 TCP 的交互式一次一个数据块(大小不等),但 TCP 把应用程序交下来的数据仅仅看成是一连串的无结构的字节流。TCP 并不知道所传送的字节流的含义。

TCP 不保证接收方应用程序所收到的数据块和发送方应用程序所发出的数据块具有对应大小的关系。

接收方应用程序收到的字节流必须和发送方应用程序发出的字节流完全一样。接收方的应用程序必须有能力识别收到的字节流,把它还原成有意义的应用层数据。

TCP 和 UDP 在发送报文时采用的方式完全不同。TCP 并不关心应用进程一次把多长的报文发送到 TCP 的缓存中,而是根据对方给出的窗口值和当前网络拥塞的程度来决定一个报文段应包含多少个字节(UDP 发送的报文长度是应用进程给出的)。如果应用进程传送到 TCP 缓存的数据块太长,TCP 就可以把它划分短一些再传送。如果应用进程一次只发来一个字节,TCP 也可以等待积累有足够多的字节后再构成报文段发送出去。

TCP 首部结构

image.png

  • 源端口:占 16bit ,用来标识发送该 TCP 报文段的应用进程。
  • 目的端口:占 16bit,用来标识接收该 TCP 报文段的应用进程。
  • 序号:占 32bit,取值范围[0, 2^32 - 1],序号增加到最后一个后,下一个序号就又回到 0。指出本 TCP 报文段数据载荷的第一个字节的序号。
  • 确认号:占 32bit,取值范围[0,2^32-1],确认号增加到最后一个后,下一个确认号就又回到0。指出期望收到对方下一个 TCP 报文段的数据载荷的第一个字节的序号,同时也是对之前收到的所有数据的确认。若确认号 = n,则表明到序号 n-1 为止的所有数据都已正确接收,期望接收序号为 n 的数据。
  • 确认标志位 ACK:取值为 1 时确认号字段才有效;取值为 0 时确认号字段无效。TCP 规定,在连接建立后所有传送的 TCP 报文段都必须把 ACK 置 1。
  • 数据偏移: 占 4bit,并以 4byte 为单位。用来指出 TCP 报文段的数据载荷部分的起始处距离 TCP 报文段的起始处有多远。这个字段实际上是指出了 TCP 报文段的首部长度。
  • 窗口:占 16比特,以字节为单位。指出发送本报文段的一方的接收窗。
  • 同步标志位 SYN:在 TCP 连接建立时用来同步序号。
  • 终止标志位FIN:用来释放TCP连接。
  • 复位标志位RST:用来复位TCP连接。
  • 推送标志位PSH:接收方的TCP收到该标志位为1的报文段会尽快上交应用进程,而不必等到接收缓存都填满后再向上交付。
  • 校验和: 占16比特,检查范围包括TCP报文段的首部和数据载荷两部分。在计算校验和时,要在TCP报文段的前面加上12字节的伪首部。
  • 紧急指针: 占16比特,以字节为单位,用来指明紧急数据的长度。
  • 填充: 由于选项的长度可变,因此使用填充来 确保报文段首部能被4整除,(因为数据偏移字段,也就是首部长度字段,是以4字节为单位的)。

三次握手过程

image.png

  1. 首先客户端向服务器发送一个 SYN 包,并等待服务器确认,其中:

    • 标志位为 SYN,标识请求建立连接
    • 序号为 Seq = x(x一般取随机数)
    • 随后客户端进入 SYN-SENT阶段(SYN已发送)
  2. 服务器接收到客户端发来的 SYN 包后,对该包进行确认后结束 LISTEN 阶段,并返回一段 TCP 报文,其中:

    • 标志位为 SYN 和 ACK,标识确认客户端的报文 Seq 序号有效,服务器能够正常接收客户端发送的数据,并同意创建新连接;
    • 序号为 Seq = y;
    • 确认号为 Ack = x + 1,表示收到客户端的 Seq 并期望收到下一个 Seq,随后服务器进入 SYN-RECV 阶段(SYN 已接收)
  3. 客户端接收到发送的 SYN + ACK 包后,明确了从客户端到服务器的数据传输是正常的,从而结束 SYN-SENT 阶段。并返回最后一段报文。其中:

    • 标志位为 ACK,表示确认收到服务器端同意连接的信号;
    • 序号为 Seq = x + 1,表示收到服务器端的确认号 Ack,并将其值作为自己的序号值,即发送下一个 Seq;
    • 确认号为 Ack= y + 1,表示收到服务器端序号 seq,并将其值加 1 作为自己的确认号 Ack 的值。
    • 随后客户端进入 ESTABLISHED。

当服务器端收到来自客户端确认收到服务器数据的报文后,得知从服务器到客户端的数据传输是正常的,从而结束 SYN-RECV 阶段,进入 ESTABLISHED 阶段,从而完成三次握手。

两次握手弊端: 三次握手的主要目的是确认自己和对方的发送和接收都是正常的,从而保证了双方能够进行可靠通信。若采用两次握手,当第二次握手后就建立连接的话,此时客户端知道服务器能够正常接收到自己发送的数据,而服务器并不知道客户端是否能够收到自己发送的数据。

我们知道网络往往是非理想状态的(存在丢包和延迟),当客户端发起创建连接的请求时,如果服务器直接创建了这个连接并返回包含 SYN、ACK 和 Seq 等内容的数据包给客户端,这个数据包因为网络传输的原因丢失了,丢失之后客户端就一直接收不到返回的数据包。由于客户端可能设置了一个超时时间,一段时间后就关闭了连接建立的请求,再重新发起新的请求,而服务器端是不知道的,如果没有第三次握手告诉服务器客户端能否收到服务器传输的数据的话,服务器端的端口就会一直开着,等到客户端因超时重新发出请求时,服务器就会重新开启一个端口连接。长此以往, 这样的端口越来越多,就会造成服务器开销的浪费。

TCP 为什么可靠

  1. 乱序重排、应答确认

    • 乱序重排:每个TCP报文段都有一个序号,用于标识数据段的位置,接收方按序号重新排序数据段,从而保证数据传输的正确性。
    • 应答确认:TCP报文包含确认号,确认号是接收方接收到的序列号 + 1。接收方收到数据时会返回一个确认报文,确认报文中会包含确认号,用于告诉发送方它已经接收到了哪些数据段(序列号是它期望接受的下一个数据段的序列号)。
  2. 报文重传

    • 超时重传:当发送端在规定的时间内没有收到接收端的确认报文,发送端会重新发送该报文。当发送方在指定时间内没有收到接收方的确认报文,则认为数据包丢失,会进行重传,以保证数据传输的正确性和可靠性。TCP会根据报文的往返时间(RTT)自动调整重传时间(RTO)。
    • 快速重传:接收方收到序号1后,回复确认号2,希望下次收到序号2的报文段,但却乱序收到比序号2大的3、4、5报文段,于是连续发出确认号为2的报文段。如果发送方连续三次收到重复的确认号,立即重发该报文段,而不管是否超时。
  3. 流量控制(滑动窗口解决):

    • TCP再三次握手建立连接时,会协商双方缓冲区window大小。如果因为接收方处理速度较慢,接收方会通过window告知发送方,实现动态调整,避免“溢出”。发送方与接收方滑动窗口大小的信息通过ACK来进行传递,ACK包含两个非常重要的信息:一是期望接收到的下亿字节的序号 n; 二是当前的窗口大小 m。发送方根据收到ACK当中期望收到的下一个字节的序号 n 以及窗口大小 m , 还有当前已经发送的字节序号 x ,算出还可以发送的字节数。
  4. 拥塞控制

    • 慢开始
    • 拥塞避免
    • 快重传
    • 快恢复
  5. 校验机制

    • TCP 报文段还包含一个校验和,用于检查在传输过程中是否发生了错误,如果发现错误,数据将被丢弃并要求重新传输。

UDP 与 TCP 的区别

  • UDP:对网络通讯质量要求不高时,要求网络通讯速度快的场景

    • 无连接,发送数据之前不需要建立连接。
    • 尽最大努力交付,不保证可靠交付,不适用拥塞控制。
    • 面向报文,适合多媒体通信。
    • 支持一对一、一对多、多对一,多对多的交互通信。
    • 首部开销小,仅 8 个字节。
  • TCP:对网络通讯质量有要求,如HTTP、HTTPS、FTP等传输文件的协议,POP3、SMTP 等邮件传输的协议。

    • 面向连接。
    • 每一条TCP有且只有两个端点,为一对一关系。
    • 提供可靠交付。
    • 全双工通信,全双工为即可传输又可接收。
    • 面向字节流。

UE4 中的网络连接

UE4 为什么要使用 UDP 协议

在虚幻里面,由于很多游戏的同步(比如 FPS)对网络延迟要求比较苛刻,所以放弃了需要三次握手的 TCP 而改用UDP(更不可能考虑 HTTP了)。

关于网络协议,游戏界经过大量的测试很早就公认——对于高频同步的游戏,使用UDP同步的效果要好于TCP。因此,Unreal使用的就是UDP协议,但是为了保证数据的可靠性,需要在上层封装一个可靠的UDP,也就是NetDriver + NetConnection + Channel那一套。

里面的逻辑很复杂而且涉及到很多模块,确实有一些冗余。此外,虽说是可靠的,但是在属性同步和RPC的处理方式上并不相同,属性同步只保证最后的数据是可靠的,中间的结果可能会丢失,而RPC则可以保证消息一定按序送达。针对其内置的RUDP的重发机制,UE其实已经做过很多次的优化和调整了,之前任何的丢包和乱序都会立刻触发重发,4.24里面已经添加了循环队列来收包矫正收包的次序,一定程度上减少了不必要的重传。

UE4 UDP是如何进行可靠传输的

总的来说 UE 使用 UDP 作为网络传输协议是为了连接速度等多方面的考虑;但这并不意味着 UE 中的网络连接就是不可靠的。相反。UE 自己实现了一套保证 UDP 可靠传输的机制。

学过计网的都知道,想基于 UDP 实现可靠传输,就需要把 TCP 可靠传输的特性(序列号、确认应答、超时重传、流量控制、拥塞控制)在应用层用类似的方法实现一遍,下面我们就开始分析 UE 到底是如何做到这一点的。

UE 的网络框架:

image.png

NetDrivers, NetConnections, Channels

  1. UNetDrivers

    网络驱动,网络处理的核心,负责管理 UNetConnections,以及它们之间可以共享的数据。对于某个游戏来说,一般会有相对较少的 UNetDrivers,这些可能包括:

    1. Game NetDriver:负责标准游戏网络流量

    2. Demo NetDriver:负责录制或回放先前录制的游戏数据,这就是重播(观战)的工作原理。

    3. Beacon NetDriver:负责不属于“正常”游戏流量的网络流量。

    当然,也可以自定义 NetDrivers,由游戏或应用程序实现并使用。

  2. NetConnections

    表示连接到游戏(或更一般的说,连接到 NetDriver)的单个客户端。每个网络连接都有自己的一组通道,连接将数据路由到通道。

  3. Channel

    数据通道,每一个通道只负责交换某一个特定类型特定实例的数据信息。

    1. Control Channel:用于发送有关连接状态的信息(连接是否应该关闭等)。

    2. Voice Channel:用于在客户端和服务器之间发送语音数据。

    3. Actor Channel:从服务器复制到客户端的每个 Actor 都将存在唯一的 Actor Channel。(Actor 是在世界中存在的对象,UE4 大部分的同步功能都是围绕 Actor 来实现的。)

在正常情况下,只有一个 NetDriver(在客户端和服务器上创建)用于“标准”游戏流量和连接。

服务器 NetDriver 将维护一个 NetConnections 列表,每个连接代表游戏中的一个玩家。它负责复制 Actor 数据。

客户端 NetDrivers 将具有一个代表到服务器的连接的单个 NetConnection

在服务器和客户端上,NetDriver 负责接收来自网络的数据包并将这些数据包传递给适当的 NetConnection(必要时建立新的 NetConnections)。

数据传输

UE 在 UDP 协议基础上,实现了一套提供不同类型服务的网络框架。既有 reliable/unreliable 的 rpc,也有可容忍中间状态丢失,但最终数据肯定一致的属性同步。可根据游戏不同业务,选择对应网络功能,最大程度利用网络。

首先,UE创建了Bunch和Packet两种数据结构,用于承载游戏数据。

  1. Bunches

    Bunch 偏向上层游戏业务,承载属性同步、rpc等重要功能。Actor在执行属性同步,或调用rpc后,会生成一个OutBunch,并向其中写入数据。

    FOutBunch 被定义在 DataBunch.h 中。FoutBunch结构如下,其中很多属性都是该Bunch的描述信息。

    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
    //
    // A bunch of data to send.
    //
    class ENGINE_API FOutBunch : public FNetBitWriter
    {
    public:
    // Variables.
    FOutBunch * Next;
    UChannel * Channel;
    double Time;
    int32 ChIndex;
    FName ChName;
    int32 ChSequence;
    int32 PacketId;
    uint8 ReceivedAck:1;
    uint8 bOpen:1;
    uint8 bClose:1;
    uint8 bIsReplicationPaused:1; // Replication on this channel is being paused by the server
    uint8 bReliable:1;
    uint8 bPartial:1; // Not a complete bunch
    uint8 bPartialInitial:1; // The first bunch of a partial bunch
    uint8 bPartialFinal:1; // The final bunch of a partial bunch
    uint8 bHasPackageMapExports:1; // This bunch has networkGUID name/id pairs
    uint8 bHasMustBeMappedGUIDs:1; // This bunch has guids that must be mapped before we can process this bunch

    EChannelCloseReason CloseReason;

    TArray< FNetworkGUID > ExportNetGUIDs; // List of GUIDs that went out on this bunch
    TArray< uint64 > NetFieldExports;

    // ...
    };

    此时Bunch中已序列化的数据称为"ContentBlock",可以简单理解为 Actor GUID + 属性同步/rpc数据。

    image.png

    之后调用 UChannel::SendBunch 函数发送该 Bunch。在 Bunch 前面按需添加 ExportBunch MustBeMappedGuids,这些也属于 Bunch 的数据部分。然后会根据 OutBunch 属性生成 BunchHeader,并添加到ContentBlock 数据前一起发送,BunchHeader + ContentBlock 才是完整的 Bunch 应用层数据包。BunchHeader内容如下:

    image.png

    BunchHeader里有很多属性,其中灰色属性要根据之前值确定是否写入。

    • 部分属性含义:
      1. bReliableBunch是否为Reliable
      2. ChannelIndex:对应哪个Channel,或者可理解为该Bunch属于哪个Actor
      3. ChSequenceReliable Bunch的序列号
      4. bPartial:是否为PartialBunch
      5. ContentBits:后面ContentBlock的大小

    Bunch 可以合并或拆分为单独的 UDP 数据包发送,这里就不再分析。Bunch的合并与拆分可以很明显的体现这是为 UDP 设计的应用层数据包协议,需要考虑网络 MTU。如果基于 TCP 设计的应用层数据包协议,则不需要考虑这些,因为 TCP 是基于流的协议

  2. Packet

    Packet为更接近网络的概念,是UE中的“数据包”,会对Bunch数据包进行一层简单的包装,然后交由UDP处理。

    Packet有两个重要的概念,为PacketSeqAckedSeq

    PacketSeq:每个Packet都有一个Seq序列号,长度14Bit,是线性递增的,到最大后回环。发送方和接收方要根据这个Seq来做排序和Ack确认,以支持PartialReliable等特性。

    AckedSeq:对某个PacketSeqAck,表示自己收到了此Packet

    注意这和代码中的PacketId是两个概念,PacketId是计数,UConnection每发送一个PacketPacketId就会+1。

    Packet头部包含了PacketHeaderPacketInfoPayload,结构如下:

    image.png

    PacketHeader包括PacketSeqAckedSeqHistoryWordCountHistory

    NetConnection每帧发送的Packet,会有一个包含PacketInfoPayload信息。包含距离上帧发送Packet经过的时间JitterClockTime,单位毫秒,以及Server上一帧tick耗时FrameTime,单位毫秒,这些用于估计链路时延。

    Packet中间会写入Bunch数据,末尾再写入bit 1,表示UNetConnection层处理结束。接收方收到Packet后,会从后往前找第一个bit 1,把后面填充的0移出。

    完整的Packet结构如下:

    image.png

    这个就是Packet的完整数据了,可交给UDP发送。

    PacketSeq回环处理:
    PacketSeq只有14位,可表示0-16383,因此一段时间后就会发生回环,那么接收方需要特殊处理Packet顺序问题。比如先收到5,后收到4,那么4就是之前发的旧Packet;如果先收到16383,后收到0,那么0就是新发的Packet,不能单纯比较Seq大小。UE做法为把16384/2(8192)作为界限,两个Seq相差绝对值小于8192,认为没有发生回环,反之则人为发生回环,取8192是认为这个区间足够一个Packet从发送端传输到接收端。

举个例子:客户端往服务器发送 RPC

  • 客户端调用 RPC
  • 该请求被转发(通过 NetDriverNetConnection)到拥有调用 RPCActorActor 通道
  • Actor 通道将 RPC 标识符和参数序列化为一个 Bunch。该 Bunch 还将包含其 Actor 通道的 ID
  • 然后,Actor 通道将请求 NetConnection 发送 Bunch
  • 稍后,NetConnection 将把这些(和其他)数据组合成一个数据包 Packet,并发送到服务器
  • 在服务器上,网络驱动程序 NetDriver 将接收数据包
  • 网络驱动程序 NetDriver 将检查发送数据包的地址,并将数据包移交给适当的网络连接 NetConnection
  • 网络连接 NetConnection 将数据包分解成 Bunch(一个接一个)
  • NetConnection 将使用 Bunch 上的通道 IDBunch 路由到对应的 Actor 通道
  • ActorChannel 解码 Bunch,查看它包含的 RPC 数据,并使用 RPC ID 和序列化参数
  • Actor 调用对应的函数

可靠数据传输的实现

TCP是一个成熟切复杂的可靠传输协议,可以广泛用于各种互联网业务场景,因此考虑比较周全。而 UE 作为游戏,可以实现适应游戏业务的可靠性协议,不需要面面俱到。因此 UE 仿照,不外乎仿照 TCP 协议,实现了一个简化版本,而其中最重要的就是Ack与重传机制

“可靠”是Bunch的概念

如果游戏需要被可靠传输,比如reliable rpc,那么它生成的Bunchbreliable属性会置为"1",再往下层的Packet则没有此概念,只管传输。从这个角度看,Bunch可类比于传输层,Packet可类比于网络层。

发送方缓存 reliable bunch 在调用UChannel::SendBunch函数发送reliable bunch时,会给它们赋予一个递增的ChSequene,写入bunch header,作为reliable bunchSeq。然后把bunch及数据复制一份,添加到UChannel::OutRec链表中,后面需要重传时再进行重传,收到接收方ack后方可删除。每个bunch都会记录所属的PacketId

RELIABLE_BUFFER 每个ChannelOutRec缓存数量有上限,称为RELIABLE_BUFFER,默认256,超过此上限后会导致Connection关闭。即对某个Actor,不能在短时间内发送太多reliable bunch

ACK 机制

要实现可靠传输,发送方需要知道接收方收到了某些数据,这样才能确定是否要重新发送,就需要Ack机制。UE的Ack机制是Packet层面的,包括AckNak

Ack表示收到了某个PacketNak表示没收到某个Packet。每个被发送的Packet都会有明确的Ack/Nak反馈。

  • 接收方产生AckNak 接收方的特点为只处理新SeqPacket,后面收到旧SeqPacket会直接丢弃,缓存乱序Packet机制也是在此规则下工作的。因此接收方可以在收到Packet后立即产生对应的Ack。但中间跳过的旧Packet,即使接收方收到了也会丢弃,因此在接收方视角,这些Packet都算丢失了,会对这些Packet产生Nak,表示自己没收到。等下一次发送Packet时,把AckNak信息都写入AckSeqHistory中,实现一个Packet对多个Seq反馈AckNak

  • History结构

    History是一个bit array,表示以AckSeq基准,各偏移位置SeqAck或者Nak。举个例子:

    image.png

    接收方上个收到的Packet Seq是1,这一帧收到了3和6,中间丢失2、4、5,那么最后的AckSeqHistory数据如图所示。

  • 发送方收到Ack 发送方收到Ack后,会遍历History,查看哪些Packet SeqAckNak了。对于被AckPacket Seq,需要遍历OutRec中的reliable bunch,找到属于此Packetbunch,把OutBunch.ReceivedAck属性设为1,表示这个bunch已经被ack了,后面可以被删除。这些操作在UNetConnection::ReceivedAck函数中完成。

重传

对于被NakPacket,则需要从OutRec中找到对应bunch,调用SendRawBunch函数进行重发,并产生新的Packetbunch对应的PacketId也要重新设置。这属于被动重传机制,不像 TCP 有超时主动重传,但如果再实现主动重传,整个协议就更复杂了,与 TCP 也更相似。

一个Ack与Nak的例子如下:

image.png

前面两个bunch收到Ack后即可移除,最后一个Bunch由于收到了Nak,需要重发。

可靠数据传输的一个完整例子如下,包括了乱序,重传。注意其中只有Packet5和Packet6包含reliable bunch数据,需要重传。

image.png

属性同步的处理

属性同步产生的bunch不是reliable的,因为 gameplay 上不需要知道属性的改变历史,只要知道当前最新属性值就可以了。但是按照上述网络传输方式,unreliable bunch丢失后,并不会重传,属性值会丢失,因为属性同步对Nak做了特殊处理SendingRepStateChangeHistory记录了属性改变对应的PacketId,如果这个PacketIdNak了,这个属性数据就要被标记为需要重发,等下次触发属性同步时合并入ChangeList,一起发送。

关于这部分的逻辑写在 DataReplication.h 中。

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
33
void FObjectReplicator::ReceivedNak( int32 NakPacketId )
{
const UObject* Object = GetObject();

if (Object == nullptr)
{
UE_LOG(LogNet, Verbose, TEXT("FObjectReplicator::ReceivedNak: Object == nullptr"));
return;
}
else if (ObjectClass == nullptr)
{
UE_LOG(LogNet, Verbose, TEXT("FObjectReplicator::ReceivedNak: ObjectClass == nullptr"));
}
else if (!RepLayout->IsEmpty())
{
if (FSendingRepState* SendingRepState = RepState.IsValid() ? RepState->GetSendingRepState() : nullptr)
{
// Go over properties tracked with histories, and mark them as needing to be resent.
for (int32 i = SendingRepState->HistoryStart; i < SendingRepState->HistoryEnd; ++i)
{
const int32 HistoryIndex = i % FSendingRepState::MAX_CHANGE_HISTORY;

FRepChangedHistory& HistoryItem = SendingRepState->ChangeHistory[HistoryIndex];

// 这里进行了特殊处理
if (!HistoryItem.Resend && HistoryItem.OutPacketIdRange.InRange(NakPacketId))
{
check(HistoryItem.Changed.Num() > 0);
HistoryItem.Resend = true;
++SendingRepState->NumNaks;
}

// ...