网络游戏同步模型

常见的网络游戏同步模型

何为网络同步,通俗点讲,就是在一个网络游戏里有玩家A和B同框,当A释放了一个技能,状态发生了变化,B又是如何及时表现A的当前状态的呢,就是通过网络同步技术。
不同的同步模型,目的都是为了保持每个客户端的状态一致,而一般客户端的初始状态是相同的,不同的同步模型采用不同的方式,其实就是在玩家有操作输入时,让所有玩家的客户端的状态仍能够保持一致。
假设客户端的某一对象的状态初始为S0,而玩家的输入为It,玩家输入后根据逻辑F产生了一个状态的变化SΔ,那么在某一时刻n的状态Sn,理论上是Sn=Sn1+SΔ,考虑到初始状态的话
image

即为了让所有客户端的Sn一致,不同的模型采用了不同的方案。当前常见的同步方案主要有两种,即锁步同步(LockStep,又被称为“帧同步”)和状态同步(StateSync),两者的本质区别在于

  1. 锁步同步。上报客户端的输入It,服务器(或某台Host主机)再定期将某一时间端内(即锁步),所有玩家的It同步给每一个客户端,由客户端计算得到状态Sn。即以客户端的角度来说,上下行包都只有输入It。
  2. 状态同步。上报客户端的输入It和(或)Sn,服务器根据It计算得到Sn,再将Sn同步给客户端。即以客户端的角度来说,上包是输入It和(或)Sn,下行包是Sn。

确定性,即给定相同的初始条件和相同的输入集,能够得到完全相同的结果。对于锁步同步,因为只同步了$I_n$,所以必须要保证严格地确定性,才能保证每次得到的状态相同,后续的状态才不会产生偏移。而对于状态同步,所有客户点以服务器的状态为准,所以保证关键状态与服务器下发的一致即可。客户端的不确定性包括浮点数运算、操作系统、算法、第三方库等等。

基于实现不同,两种同步模型在某些方面的特征

image

虽然在一些方面表现出了差异,但是关于哪类游戏应该选用哪种同步模型,除了一些对某些要求比较极端的游戏类型更适合哪种模型之外(比如对实时性有着极强要求的格斗类游戏(FTG)适合使用锁步同步,而有大量玩家同时在线的MMORPG适合选用状态同步),没有严格的选用标准。一般围绕着核心玩法、安全性、开发维护成本等方面进行评估。

而作为一个单局PVP为主的FPS游戏,单局内可观察的网络对象较少,网络流量负担较小,而PVP要求保证公平性,以服务器的算结果作为权威,在安全性有更大优势的状态同步更为合适,且没有客户端不确定性的风险,开发负担更小,版本质量更加可控。

二、TCP or UDP

为大家所熟知的,TCP和UDP主要有以下几个特点
TCP:

  1. 基于连接的
  2. 可靠保序的
  3. 自动分包的
  4. 有流量控制

UDP:

  1. 无连接的
  2. 不保序、不可靠的
  3. 需要手动分包

一眼看上去,似乎tcp是最佳的选择,拥有我们想要的所有东西,使用起来也非常方便,但正是因为它部分强大的功能,在某些情况下,特别是在弱网情况下,会对网络的实时性会造成严重的影响。其中包括

  1. TCP默认会开启Nagle算法。Nagle算法的实现是:数据只有在写缓存中累积到一定量之后,才会被发送出,通过减少需要传输的数据包数量,来优化网络,这将会造成一定的延时。虽然可以通过设置TCP_NODELAY的选项来关闭这个算法功能。
  2. 实现可靠保序的方式。TCP为我们提供了可靠保序的保证,但对于时效性强的数据来说,这个代价过于巨大。当一个包丢失时,接收方会无法获取后续到达的包,直到收到这个包为止(延迟到达或是重传)。对于一次丢包而言,超时重传时间大约是$2RTT$,再加上发送到接收的$0.5RTT$,所以丢了一个包就会有接近3个RTT的延迟。而状态同步很多时候与其收到每一个包,收到最新的包反而是更重要的,有些包丢掉也没有关系,比如后续新的状态$S_n$会直接覆盖掉之前的状态,而包含$S_{n-1}$状态的包即使被丢掉也没有关系。
  3. 拥塞控制算法,包含了慢开始、拥塞避免、快速重传、快速恢复,慢启动就是连接刚建立,一点一点地提速,试探一下网络的承受能力;拥塞避免算法可以避免窗口增长过快导致窗口拥塞,而是缓慢的增加调整到网络的最佳值。两者都是为了实现更好的公共网络环境,而牺牲了一些自己的网络性能。

以上TCP影响实时性的几点特性,除了1可通过配置避免之外,其他两个都是无法做出调整的,这导致了相对于UDP,TCP的传输速度慢很多,特别是在弱网的情况下,会大大影响游戏的体验。所以对网络实时性有要求的网络游戏,基本都采用UDP作为传输的协议,再根据需要,基于UDP开发一套可靠的协议。

三、基于UDP开发的协议

其实选用UDP的原因只是TCP的那几个严重影响实时性的功能无法关掉而已,而TCP关于连接的概念、可靠保序的实现方式等都是值得借鉴的。下面是在UDP之上实现的一套协议

1. 连接

连接可以有多种状态,比如开始连接、连接中、断开连接等,通过这些状态,我们可以知道客户端和服务器的交互情况,玩家是否正常游戏。

  1. 连接的标识,需要在UDP的基础之上建立一个连接的概念,标识一个唯一连接的方式有很多,比如客户端请求的IP和Port。而对单局,则使用全局玩家唯一标识id作为连接的标识,单个玩家只可建立一个连接。
  2. 连接的建立,客户端发送建立连接的请求,服务器校验通过后,返回确认包,此时服务器认定已经成功建立连接,客户端收到Ack后确认建立了连接,如果Ack包丢了,客户端重发请求,服务器收到后再次回Ack。
  3. 建立的校验,服务器的连接资源是有限的,为了防止恶意连接,需要对建立连接的请求进行校验。可选用的一种简单方式是,服务器收到请求后,给客户端发一个验证包,客户端需要某种操作之后再将结果包回给服务器,连接才会建立。简单的做法是,在玩家开局之后,room就会请求pvp为每一个guid预先建立一个connection,只有拥有该connection的id才可以建立.
  4. 连接成功后,客户端可发送数据包,服务器在tick中处理处理每一个包并进行统计记录。
  5. 客户端的主动断开,客户端发出断开的请求,服务器设置标志位让出资源,并上报该链接的全局统计结果。
  6. 数据加密,简单的做法是对包内容的加密只是做了简单的异或处理,每个连接都会有一个密钥,将密钥与数据按照某种规则进行异或的操作。

2. 可靠性

2.1 QOS

根据对可靠性的不同需求,一般会实现不同可靠程度的通信通道(channel),包括

  1. 不可靠不保序通道
  2. 不可靠保序通道
  3. 可靠保序通道

只实现了不可靠保序和可靠保序。(不可靠不保序应用价值不大?)
两者的实现都是基于数据包Package的序列号Seq实现的,每个channel记录了两个seq : 1.当前channel的从socket收到的最大的seq(last_recv_seq_);2.当前channel已经处理的数据包的seq(recv_read_seq_)。在上层从调用接口(ReadData)读取缓冲区数据时,两者的处理方式不同

  1. 不可靠保序,读取缓冲区内从recv_read_seq_到last_recv_seq_的所有数据包,如果包未到达则算丢失,不重传。
  2. 可靠保序,只读取recv_read_seq_的包,如果该包未到达,则直接返回,直到该包到达为止,具有超时重传和快速重传策略。
  3. 如果实现不可靠不保序的话,应该直接读取当前缓冲区内所有的包,再根据需要制定判定丢失的规则,不重传。

2.2 重传机制

重传的策略有两个,超时重传+快速重传。
数据包触发重传的条件有两个:

  1. 到达超时时间,即 Now < send_time + RTO ,其中RTO的参考计算公式为
    image

    DevRTT是Deviation RTT。在Linux下,α = 0.125,β = 0.25, μ = 1,∂= 4 。一般为了防止极端情况下rto过大或过小,可以对rto的值进行”钳位“。

  2. 比此包更新的数据包已被确认,且此包发送次数小于等于1时(即最多发过1次),也会立刻触发一次重发。

3.包的结构

3.1 数据包头PkgHead

1
2
3
4
struct PkgHead {
int seq; // 超时重传依据该seq
...
}

3.2 网络包包头DataHead

1
2
3
4
5
6
7
struct DataHead {
int cmd; // ACK确认以及RTT等统计依据该seq,网络包不会重传,网络包的内容可以是重传的包
...
int last_ack;
int ack_bits; // 服务器已收到的客户端包的最近的32个序列(即是服务器的ack序列),大于32即算丢包
...
}

4.数据压缩

对网络包的数据字段进行了压缩,压缩算法采用LZ4。
https://lz4.github.io/lz4/
从其github上所贴出的测试数据可以看出,该压缩算法在压缩、传输和解压的综合性能上相比其他算法还是比较优秀的。对服务器性能的影响较小,且能节约带宽。

5.加密

加密采用了简单的异或操作,将压缩后的包体根据特定规则生成的密钥进行异或操作。密钥由单局分配connection时进行生成,并通过开始单局的ntf下发给客户端

REF

-->