构建联机网络库
为什么需要一个网络库或者说网络库是干什么的
对任何一个联网游戏而言,其联机部分的业务逻辑都需要一个网络库来支持。一般而言,这个网络库需要实现以下的需求:
1 | 1.提供简单的Send/Recv/Accept/Listen接口 |
如何实现
从传输协议选择来看,几乎绝大部分游戏在联机相关业务逻辑上使用的都是UDP协议,由于TCP协议本身的一些特点,导致其在游戏等实时性要求较高的领域上表现并不佳,相关的讨论网上有很多。但是使用UDP协议的时候必须要解决一个问题,那就是传输可靠性。由于网络本身的不稳定因素,UDP协议并不保证传输内容是否能够准确到达目的地,这就需要你设计一套算法来保证传输可靠性。
除此之外,我们在传输数据时经常会做一些压缩,加密的操作,这些也是可以直接在网络库中直接实现的。因此,简单总结来说我们需要做的是一个“UDP协议 + 可靠传输算法 + 上层额外功能”的网络库
可靠传输算法
对于网络传输过程中遇到的丢包问题,所有的解决方法归结到最后就是两个字–重传。但是何时重传,重传哪些数据就有很大的操作空间了。绝大部分的网络库实现可靠传输算法使用的都是类似于TCP协议的send-ack模型。这种模型简单来说就是在发送的时候给每个数据包打上一个递增的序列号,发送出去后不会丢弃掉这个数据包,而是放在自己的队列中。当接收端收到这个序列号的TCP数据包时,就会往发送端返回一个ack包确认自己已经收到这个序列号的包。接收端如果收到了这个ack,就可以确认这个数据包已经被对方接收到了,可以从队列中移除它了。如果经过一段时间发送方没有收到队列中数据包的对应的ack,就会重新发送这个数据包,直到收到ack。
对于这种模型而言,在编写代码时有一些小技巧。
序列号循环使用,每个数据包的都会有一个序列号,并且我们希望它尽可能的小,能使用int16就尽量不要使用int32,但是当发送的时间长了,序列号超过int16上限怎么办?答案是循环使用。不过这样可能会有一个问题:接收端收到一大一小两个序列号的包,到底谁先谁后呢?其实也很简单,计算两者的差的绝对值,如果小于1/2个int16.maxValue,就可以认为较小的值先。如果大于,那么就认为是已经循环了一轮序列号了,较大的值才是先发的包。(这里认为不会出现某个包在序列号已经用了一轮的情况下才终于发送到接收方的情况)
数据的合包,用户发送的数据是长度不一的,有的可能很大,有的可能很小。频繁的调用底层接口发送小包显得很浪费,而一次性发送某个很大的包时会被底层拆分成一个个小于mtu(最大传输单元,以太网一般为1500 byte)的数据包依次发送。而对于udp而言,只要这批次中有一个包丢了,整个数据大包就会认为全部丢失了且需要重传,这无疑是极大的浪费。所以如果我们可以把用户的数据整合一下,小包合并,大包拆分。最终以略小于mtu的大小依次发送出去,这样就会有效的提高传输效率。当然,在合包和拆包的过程中难免会有额外的流量开销,但是由于小的数据包被合并了,压缩就会有比较好的效果了,这样实际上会节省流量。重传策略,一般而言在发送完某个数据包之后,会启动一个定时器,当定时器时间到了以后如果还没有收到ack,就会触发定时器重新发送数据包。但是,当数据包数量上去了以后,启动如此多的定时器显然是一笔巨大开销。在TCP中有个叫做滑动窗口的结构,可以较好的解决这个问题。我们简单的把发送出去的数据包全部缓存在一个优先重传队列中并记录时间戳。然后单独启动一个定时器定期检测这个队列中数据包是否收到ack了,检测周期理论上应该是2倍的rtt值,但是可以适当放宽到3~4倍的rtt值。需要注意的是这个队列应设置上限,如果发送数据包完后发现这个队列满了,就放到另一个更大的缓冲区域内,这个缓冲区没有上限,但是不会定期检查ack情况,只有优先重传队列不满的情况下才会从这个缓冲区中取包塞进去。如果频繁出现优先重传队列满了的情况,可以考虑放大队列的长度。关于ack包,一般而言ack是不重传的,不过可以做很多优化,比如一个ack包中冗余多个确认的序列号。这样可以减少因为ack包丢包导致产生的重传。
加密,比较常见的做法是先使用diffie-hellman秘钥交换算法得到一把双方都有的公钥,然后再基于这个公钥使用其余的加密算法,rc4/sha128/256等。当然,为了保证数据的完整性,我们还应该在数据包中加上签名,比如加上一个md5签名在包头。
设计适合游戏特性的接口
绝大部分游戏都有帧这一概念,因为绝大部分游戏的主逻辑都是在一个死循环内定期update。而往往网络层的收发包,都会在update里面执行。所以很明显我们必须提供给游戏用的是非阻塞的接口,因为如果阻塞了游戏的主循环就直接卡住了。
前面提到的合包策略中有一点其实也是很适合游戏的,那就是合包算法需要一个合适的时间把缓冲区中的包全部发送出去,而游戏定期update的这个特点正好满足。每次update的最后flush一下缓冲区中的数据,正好避免缓冲区因为等待太久导致数据没有办法及时送达。
还有就是绝大部分游戏的连接需要能够检测到自己什么时候与服务器断开了连接,所以可以在传输层做一个类似keep-alive的功能,这样应用层可以随时知道自己的状态。
一些值得借鉴的其余网络库
KCP
kcp在国内算是比较有名的开源可靠网络库,由于其kcp是一个纯算法层面的可靠传输层实现,故很容易被port到别的语言上(目前已经有C#,Golang,),同时网上也有比较多的分析这里就不赘述了。
GameNetworking Sockets
GameNetworking Sockets是valve在今年开源的自家的使用C++写的网络库,也是基于UDP协议。被广泛应用于v社自家的项目中,如CS:GO,Dota2等。根据github上的描述,提供了类似于TCP的面向连接的API,但是又是基于消息的数据传输方式(而不是流),同时支持可靠不可靠的信道,内部自带合包/拆包,在可靠性上面也是参考了巨佬Glenn Fiedler的文章以及Google QUIC实现的一套基于send-ack的算法并且也自带了网络模拟的工具链(已经是开源网络库的标配)。但是由于其本身是从valve庞大的祖传代码库中抽出来的一部,故代码中会有很多奇怪的命名(比如无数个steamxxx,steamworkxxxx),并且负责开源的员工似乎只会C++,并没有提供比较好的C接口供别人封装成dll来让别的语言调用(参见其项目中排名第一但是至今还没有close的关于C#的wrapper的issue)。所以除非你的项目是C/C++的并且没有时间自己开发一套网络库,否则都不建议使用该库。
Netcode.IO
Netcode.IO是一个开源的基于UDP的面向连接的网络库。原版是游戏网络传输方面的巨佬Glenn Fiedler用C写的,非常干净利索,现在已经被port到C#,Golang,Rust,并且有专用的适合Unity,UE4的版本。这里我以.net版本的实现为例进行研究。
.net版本的实现结构大概分2层,Public层和Core层。
Public层:由于是为了游戏而专门设计的,所以在这层为上层应用封装了Client和Server两个类。
- Client:对外暴露了简单的Connect/Send接口,内部主要靠Tick(即内部有一个循环定时发送自己缓冲区的数据,读取远端发送过来的数据并处理)来驱动。提供了个一些重要事件的回调(onStateChanged/onMessageReceived等)内部实现了加密,keep-alive
- Server:提供了ClientConnect/DisConnect,MessageReceive之类的回调,在内部定义了一种RemoteClient类来表示连接上来的客户端便于管理。同时在构造Server时就需要指定能容纳的最大的Client数目,比较适合做那种有人数上限的房间制的游戏,内部也是靠Tick驱动。
Core层:定义内部用的网络包格式,也是比较常见的前若干个字节分配个PacketHead,然后PacketHead里面会有一些描述信息,重复包的检测也依赖于包头的数据。
加密也是在这里实现的(加密的实现较low,客户端和服务器共用一个私钥,然后利用这把私钥对第一个Connect包加密,客户端在发送这个Connect包时会为其添加两个key,一个用来给发出去的包加密,一个用来给收到的包解密,客户端和服务器共用这两个key,由于非法的Connect包会被直接忽视掉,由此达到其声称的抗DDos效果…)一些工具类:
自己实现了诸如LockFreeQueue,BufferPool,DateTimeExChange,KeyGenerate之类的工具类,造了一些轮子,主要是因为作者写的时候按照的标准是 .net 3.5,并且他和我一样希望做到pure managed implementation and use zero native DLLs or wrappers
。
底层传输基本是调用的.net提供的socket API(自己稍微封装了一下原生的API,定义了一个ISocketContext接口,这样方便做测试)。
总的来说.net版本的实现中规中矩,自定义了一个ISocketContext接口方便之后写一些模拟的Socket层来模拟丢包之类的测试算是一个亮点,并且代码也比较简洁清爽(强烈吐槽C版本动辄8000行+的单个大文件)。
Reliable.IO
上面的Netcode.IO并不能保证数据的可靠性,一般来说需要在其之上实现一套重传算法来保证消息的可靠性。上面C#版本的作者基于原版的Reliable.IO也实现了一个pure managed C# socket agnostic reliability layer。ReliableNetcode里面定义了3种信道:reliable、unreliable、unreliableOrderd。其中unreliableOrderd信道非常适合做游戏中诸如状态,位置等数据的传输,因为很多时候这些属性只需要最新的数据就行了。
其中reliable信道的实现是传统的send-ack方式,主要依赖两个队列,一个叫做sendBuffer,一个叫做messageQueue。上层应用调用send接口后并不会立即将消息发出,而是尝试往sendBuffer里面放,如果满了则放到messageQueue中,并且在存放数据的时候都会打上一个唯一的序列号来标记这个数据包。然后内部会定期的调用update函数,在update内部会尝试将sendBuffer中缓存的数据包重组成一个个指定大小的包(一般为mtu左右)然后再发出去。并且实际上发送时调用的是外部传递进来的回调函数,这样就比较好的达到了重传算法和底下传输层实现的解耦。接收方接收到了这个ack包之后会根据数据包中包含的唯一的序列号发送一个ack给发送方,发送方根据ack中包含的序列号从sendBuffer中移除相应数据包并更新相关数据,中间丢失的包或者没有收到ack的包会被重传直到收到ack。
install_url
to use ShareThis. Please set it in _config.yml
.