天天飞车P2P混合式网络架构
在移动端,网络环境异常复杂,手游的网络模块要能适应弱网络和断线重连的情况。它究竟与PC网游有哪些不一样的地方?最近,《天天飞车》发布了新版本,通过P2P支持多人实时对战。相信后面移动端的实时对战玩法将来越越常见,可见手游在网络模块的要求也将越来越高,本文将详细分析《天天飞车》的网络模块。
让我们先从整体的布局开始,先有一个宏观的了解。在我们的网络模块里面,即有TCP,也有UDP,其中TCP用于业务逻辑,UDP用于多人游戏的单局内玩家位置速度等信息的同步。
一、整体架构
名称 | 注解 |
TCond | 公司的底层通信组件 |
CSocket | 封装了C#的System.Net.Sockets,负责对Buffer的异步发送与接收,它会把外面传进来的Buffer复制到缓冲区,这样这样外层不需要管Buffer是否已经发送完 |
TConndServerHandler | 负责对协议进行序列化反序列化,加解密,压缩,统计流量 |
TConndUtil | 负责对加密算法进行封装 |
Tdr | 公司的协议组件,能将以XML定义的数据结构和协议转换成C#/C++形式的协议代码,所生成的协议体将带有序列化与反序列化的函数 |
WTLoginPlugin | 公司的QQ/微信登陆组件,以DLL形式接入 |
LoginInfo | 对登陆相关的操作与信息进行封装 |
WordSvrManager | 负责对解释域名,进行ping操作获取最优登陆服务器 |
Network | 负责协议的接收与发送,内部有发送队列,对于Request类型会检查回复超时 |
irNetworkManager | PVP模式下的混合式网络切换管理 |
以上对关键的名词进行了注解,经过层层封装之后,进行通信编码变得相当简便,以下是发送一个协议的操作例子。其中ResponseMulUDPHello为这次Request的Response处理函数。
究竟这个操作在底层会发生什么事呢?下面通过时序图来解析底层的调度关系:
需要注意的是,以上是不需要压缩的协议的发送接收流程。当一个协议的大小超过一定的阈值,在把协议转成buffer之后,会通过ZLib来进行压缩,然后把压缩后的buffer放到一个压缩协议里再进行发送。
二、配置信息下发
尽可能地暴露参数到配置,可以让游戏具有高度的灵活性。另外当游戏遇到Bug时,甚至可以修改参数来临时规避,因为苹果安卓的版本发布需要漫长的审核时间。但是,随着游戏版本不断迭代,游戏所涉及的配置将越来越庞大,比如一把武器,每个等级都有不同的参数。如果仍旧用传统的协议方式进行配置的下发,数据会变得越来越乱。是否有更好的解决方案呢?
公司的CDN是一个很不错的组件,能把Excel文件转换成二进制配置文件,然后通过服务器进行下发。
通过Excel表进行配置会非常直观,策划没有学习成本,另外可以充分利用Excel强大的公式功能。
只需要用XML定义好数据结构体与Excel表的关系,即可通过工具进行转换。
然而,CDN方案也不是完美,因为Excel表是二进制,SVN将没法进行合并,所以维护Excel表时得轮流进行操作。另外在2G/3G的情况下,下载多个小文件比较耗时。为了改善这种情况,我们目前已经把所有配置打成一个大文件来下发。
三.混合式网络
在做实时对战玩法下,涉及位置速度的同步,这种同步包比较小,但频率高。如果用TCP进行通信,在网络环境不好时,效果将非常差。因为如果一个包有比较大的延时,后面的包也会被塞住。对于那些允许丢包情况出现的通信,UDP是更好的选择。
另一方面,如果同步包通过服务器进行中转,无疑会严重影响服务器的在线承载量,所以P2P是首选的通信方式,这也是PC端的《QQ飞车》能承载500W同时在线的重要原因。
然而,如果仅用UDP进行P2P同步,会有通信建立失败的风险。各种通信方法都有其优缺点,是否可以让它们互补呢?
事实上,移动端的网络环境非常复杂多变,各种通信方式混合在一起,自动实时切换最优通信方式才是最好的方法。
在我们的混合式网络模式里面,客户端会先用UDP_P2P进行通信,如果中途超时,将会让服务器进来中转。向服务器的发送,或者服务器向客户端发送的方式又分UDP_CS和TCP_CS两种,各通信节点会根据超时情况进行逐个通信方式的切换。
如图所示,为一种可能情况下的通信状态。假设Client1在UDP接收上有问题,在一开始,Client1向Client2的发送以UDP_P2P进行,然而游戏过程中,Client2发现一段时间内自己向Client1的UDP包都没有到达,于是以UDP_CS的方式让Server来中转。Server一开始也是以UDP_CS的方式向Client1发送,但游戏过程中,也出现了一段时间内自己向Client1的UDP包都没有到达,最后不得不切换到TCP_CS。
手机进行UDP_P2P不一定通的,在我们的实际测试中发现:(1)如果手机是用2G/3G,它将没法接收来自非服务器的UDP包,但能发送UDP包到使用WIFI的客户端,从而建立单向的P2P,但注意这点在家用WIFI测试可行,在公司WIFI下无法建立,应该是受防火墙影响;(2)使用同一个WIFI的两台手机,如果直接采用公网IP和端口,有可能没法建立P2P,取决于路由器策略,建议此时互相之间使用内网的IP地址和端口或者直接走UDP_CS。
四、检测通信超时
在我们进行位置同步时,是不需要Response的,那如何检测先前的UDP通信是否被收到呢?Client1为了高效地判断自己发送的包是否到达Client2,在发送的包的包头,将会带上本地时间戳,Client2要负责把收到的最新时间戳记下来,然后在主动/被动地向Client1通信时,把这个时间戳带回去。Client1会记录Client2的最新返回时间戳,如果在某一次向Client2通信时,发现当前本地时间与所记录的返回时间戳超过了阈值,就会切换通信方式到更可靠的一层。
在网络体系里面,每个客户端与服务器都独立维护向其它每一个通信节点的发送方式。
如图,只要对方跟自己保持持续通信,时间戳就能持续更新,红色处我们称为时间窗口。
如果返回时间戳与发送时间戳一致,说明最近一次通信是通的,这种情况下不应该进行超时检测。
如果返回时间戳与发送时间戳相差超过阈值,说明发送出去的包未能到达。此时应该切换通信方式,并重置上面的变量为当前时间。
值得得注意的是,这里有两种情况。
(1)假如UDP通信不是持续性的,为了让上述方法更好地工作,得加入TCP心跳包进行通信饱和。因为如果没有TCP心跳包,刚好最后一次发送未能到达,并且在很长一段时间内又没有进一步的通信,就会被误解成通信中断。
(2)假如UDP通信是持续性的,但只是纯UDP通信,没有TCP做饱和。如上图所示,那我们还得区分是自己发送未到达,还是对方发送未到达。如上图所示,当两者都进行UDP通信时,如果Client1的发送未到达,那会造成两者的返回时间戳都过旧,导致两者都切换发送方式,其中一方可能原本是正常的也进行了没必要的切换。所以两者还得记录对方给自己最后一次通信的本地时间,通过与当前本地时间进行比较,Client1会明白Client2向自己的通信是通的,自己向Client2的通信不通。而Client2由于长期没收到Client1的包,能确认Client1向自己的通信是不通的,但没法知道自己向Client2的通信是否通。此时Client2可以把超时时间再延长一点,等Client1切换完后向自己通信。如果延长之后还是超时了,那Client2也只好切换通信,以防万一。
五、事件同步
游戏里面的重要事件是需要通过TCP经服务器进行校验的,如图所示是一次导弹使用的通信过程。导弹的路径和爆炸效果都是每个客户端本地模拟的,而ClientVictim被打中时,会把受击事件通知给ClientAttacker,这时ClientAttacker才会表示远程玩家的受击表现。个别道具的使用采用了先表现后校验的做法,比如护盾的使用很高的实时性,我们会让玩家不等Response的情况下直接表现护盾。
六、Dead Reckoning同步算法
在移动端,流量是非常宝贵的。在PVP研发初期,我们尝试了QQ飞车的同步方法:以固定的频率发送同步包。虽然已经把同步包的频率提升到4个/s,但Demo的效果仍然难以令人满意。主要是因为在我们的PVP玩法里面,玩家会频繁地进行左右移动,如果在发送同步包之后立即进行转向,对方预测出来的情况跟实际情况将刚好相反。究竟有没有办法能用极少的流量做到优秀的同步效果呢?
后来,我们采用了一个国外很经典的网络同步算法:Dead Reckoning(航位推测法)。其实它的概念相当简单,本地玩家发送自己的同步包后,要把这个同步包记录下来,然后按远程玩家的预测算法,每帧都计算出自己在远程玩家那的预测结果。然后本地玩家比较预测结果与自己实际情况的差距,当两者位置上相差超过一定的阈值时,就向远程玩家发送下一个同步包。
如上图所示,红色线为本地玩家的真实移动路径,DR Threshold为允许的预测误差范围,DR Path为远程玩家所预测的自己的路径。可以看到,在T1时误差超过了阈值,远程玩家接受到新的同步包后已经是T1',这里有时间误差。所以同步包上还得带上一个时间戳,为距离比赛倒数开始的时间差,这样远程玩家就可以根据这个时间戳补尝网络延时,计算出相对于当前时间的真正位置。
另外预测与插值两部分的计算方法,可以根据实际情况来采取合适的方法。
七、异步Socket
对于网游来说,Socket几乎都是采用异步的方法来进行收发的。在调用Socket.BeginSend 或Socket.BeginReceive时,会传入一个异步回调函数,当发送与接收结束时,系统的线程池里面会有线程调用我们所传进去的函数。所以,调用回调函数的线程,与我们游戏逻辑线程是不同的线程。当出现不同的线程对相同的变量进行操作时,往往容易出现条件反射式地加锁来进行保护。事实上,不良的同步方法很容易对游戏的性能产生负面影响。为了性能着想,是否可以不用锁来进行异步的数据收发呢?
我们的服务器和客户端在进行异步Socket时并没有使用锁,事实上,在某些多线程的模型比较简单的情况下是可以考虑不用锁来实现的。首先,我们要理清是否存在数据竞争,是否需要互斥。
以上是一个很常见的递增操作,它究竟是不是一个原子操作?
如果转换成汇编,我们可以发现,CPU会先把a的值存到自己的寄存器EAX上,再进行递增的操作。
假如有两个线程t1和t2分别进行了这个操作,就可能出现上图所示的执行顺序。t1和t2分别把a的值存到自己的CPU寄存器EAX上,当t2进行了它的操作后,t1把它自己的结果覆盖了t2的结果。导致最终a的值是1,而不是所期待的2。
那怎么才是原子操作? 事实上,在32位机器上,向对齐的32位内存所进行的操作,都是原子操作。所谓的对齐,比如未经过padding的数字变量,Buffer的开端,都是对齐的。所以,对一个数字变量进行赋值,是一次原子操作。
如图所示,在CScocket上有一个接受缓存区,在Socket的异步接受回调函数里,来自系统线程池的线程会对mRecvTail进行递进操作。而在外层,主线程会不断地去读取缓存区上的数据,对mRecvHead进行递进操作。这咋一眼看上去有点像生产者消费者模型,但实际上经典的消费者模型里面是多个写者的,而这里并不需要对Buffer本身做访问互斥。
线程 | 函数 | 变量 | 操作 |
1 | Enqueue | mRecvTail | 读+写 |
1 | Enqueue | mRecvHead | 读 |
2 | Dequeue | mRecvTail | 读 |
2 | Dequeue | mRecvHead | 读+写 |
为了方便论述,我们把代码简化成上面的例子。假定有一个线程进行上面的Enqueue操作,有另外一个线程进行类似的Dequeue操作,在不用任何锁进行保护的情况下,这是线程安全的。
比如,当线程1执行到第7行,线程2对mRecvHead进行了修改,会发生什么事呢?由于mRecvHead只会向前递进,所以它不会影响线程1先前判断Buffer是否满这个结果。而在线程1执行到第10行,对Buffer进行填充时,由于是在[mRecvHead, mRecvTail)这个区间外,所以也不会影响到线程2对Buffer的访问。以上对于Dequeue函数也是同理的。
事实上,这种不采用锁的多线程编程叫做Lock-Free Programming。另外,操作系统提供的锁和信号量之类的互斥方法,其实都是基于原子操作的硬件指令Compare And Swap(CAS)。比如在Win32下可以通过InterlockedCompareExchange来使用这个指令。
假如在某些情况下需要对数据做线程同步,并且数据操作模型比较简单,我们可以利用CAS来实现而不是用系统重度的互斥方法,这对性能会有很大的提升。
八、总结
天天飞车的网络模块,采用了混合式网络的方式,从而具备良好的网络适应能力。在移动端上使用P2P技术,避免了阻塞问题并提升了服务器承载能力。巧秒的超时检测方法,从而省略了回包流程。应用经典的Dead Reckoning同步算法,以极少的流量做到不错的同步效果。无锁的异步Socket,避免了线程之间互相等待的性能问题。
相信实时对战的玩法在移动平台上将越来越广泛,对网络模块的要求也将提升到一个新的水平。