基础流程
客户端流程:
- socket.Connect(远程IP地址,远程端口)
- socket.Send/BenginSend发送数据
- socket.Receive/BeginReceive接收服务端数据
- 网络操作(如心跳协议)
- socket.Close关闭连接
服务器流程:
- listenfd.Bind(ipEp)将创建的空套接字listenfd绑定到IP+端口。
- listenfd.Listen(backlog)开启监听
- listenfd.Accept/BeginAccept接收客户端连接。Accept/EndAccept返回连接的Socket对象,对于服务器来说,它有一个监听Socket(listenfd)用来监听(Listen)和应答(Accept),对每个客户端的连接再创建一个专门的Socket(connfd)用来处理该客户端的数据。
- connfd.Receive/BeginReceive 接受数据
- 网络操作(如心跳协议)
- socket.Close关闭连接
服务器处理socket阻塞
由于服务器需要对接多个客户端,因此服务器不能在一个客户端处阻塞等待,而需要进行特殊的阻塞处理。
异步操作(性能最优):使用Socket的异步API,把阻塞轮询交给内部。异步处理都由BeginXXX
API进行异步等待开始,并且绑定一个回调函数CustomCallback
,这样在异步响应时就会使用回调函数来进行处理。在回调函数内部,首先使用对应的EndXXX
API来拿到响应数据,对数据处理完成后,可以在回调函数内再次调用BeginXXX
,以串行开启新一轮的异步等待。代码示例如下:
1 | public static void Connect(string ip, int port) |
C#和.NET中的异步IO使用了操作系统的IOCP (IO Completion Ports) 线程池,从结果来看,程序异步等待的代价可以忽略不计。参考
Poll和Select轮询:比起异步程序,同步程序更简单明了,而且不会引发线程问题。既然诸如Receive的Socket操作会阻塞,那为了避免阻塞,我们在正式的Socket操作之前可以先检查Socket状态,只有有效时才正式进入Socket的阻塞操作。而这就是Poll
方法干的事:
socket.Poll(int microSeconds,SelectMode mode)
Poll会检查调用方socket的状态,
microSeconds设置检查的阻塞时间,超时且状态无效则会返回fasle,以便跳过后续处理。Poll本质上就是轮询。相比于直接使用socket.Receive
阻塞在一个socket上,使用poll可以不停地轮询所有的socket.但由于其本质上一直在循环查询,因此其CPU占用率会较高。
多路复用Select方法和Poll类似,其同时检测多个Socket,并返回通过检查的sockets。
public static void Select(IList checkRead,IList check Write,IList checkError,int microSeconds)
Poll/Select 内部都是存储一个文件描述符fd集合来表示关注的sockets列表,然后通过操作系统去检查IO,并对应修改fd。回到用户程序时,程序则遍历检查修改后的fd集合,来检查哪些sockets完成IO,整个过程消耗O(n)时间。Poll和Select的区别在于存储fd集合的数据结构不同。
linux中还有一种epoll的优化方法。从Poll/Select的函数调用可以看出,每次调用都会临时传入待检测的fd数据,而没有在内部持续保存。因此,一方面,epoll创建了一个内核中的红黑树来跟踪维护所有epoll_ctl
注册的socket,避免重复的数据拷贝和内存管理。另一方面,epoll会维护一个就绪链表。当一个socket的IO事件触发时,就会把这个socket加入到就绪列表,这样在用户程序获取就绪socket时可以直接拿走链表数据,而不需要再去遍历检查所有socket。
消息通信
当socket连接建立完成后,就需要让C/S两端进行消息的通信。而为了针对不同的消息响应不同的功能,我们首先需要定义一下通信协议如消息长度消息名称|参数1,参数2,...
,这样我们就可以通过字符串分割或序列化处理解析消息。
不过实际上发送消息时socket.Send
并没有真的发出网络包,而是写入到了操作系统的socket发送缓冲区中,而什么时候发送则由操作系统决定,而socket.Receive
也只是从socket接受缓冲区中提取数据,这两者都不保证消息的完整传输,因此就会出现缓冲区的黏包半包问题:比如多条消息累积在缓冲区,被接收方一次性提取。又或者由于网络是分包传输的,接收方可能先收到部分消息,一段时间后再收到剩余消息。总而言之,我们需要确保消息传送的完整性,通常会对消息协议进行以下几种改造:
- 附带长度信息,标识消息完整长度
- 附带结束符号,标识消息的结束
- 固定长度
最终,附带长度信息后的发送消息格式如下:
1 | byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr);//将消息本体 "消息名称|参数1,参数2,..." 转为字节 |
接收消息同样需要保证接收消息的完整性,因此可以先构造一个接收缓冲区存储socket.Receive
的数据,并等待完整消息的到达。
1 | byte[] readBuff = new byte[1024]; |
有了缓冲区之后,接收放即可尝试在缓冲区里提取数据:
- 缓冲区数据<=2字节,连长度信息都不完整,等待。
- 缓冲区数据>2字节,读取头部2字节获取消息长度,接收字节<消息长度,不完整,等待。
- 缓冲区数据>2字节,读取头部2字节获取消息长度,接收字节>=消息长度:即存在一条完整消息。按长度取出该条数据,并挪动缓冲区:
Array.Copy(readBuff,msgStart,readBuff,0,count);
但是数组移位是一个低效的办法,因此可以把缓冲数组做成循环数组,同时还可以考虑自动扩展等优化功能。
大端小端问题
完整的字节数据发送流程已经构建好了,但是字节数据本身还存在一些问题(虽然BitConverter已经给我们隐藏好了)。网络传输中我们一般习惯大端字节序,即一串字节数据中,高位在低地址:A1=10*16+1*1
,这种模式更像我们平常阅读字符串的模式。然而,常见的x86、ARM处理器是小端字节序,即一串字节数据中,低位在低地址A1=10*1+1*16
,这种模式更贴合内存读取的顺序。如果要手动解决两种模式的差异,可以在数据协议中规定好以一种模式为主,例如将所有字节数据都转换为小端存储。
发送不完整
我们已经知道socket.Send
只是把数据写入缓冲区,其同时有一个返回值,告诉我们写入了多少字节。这说明即使是写入缓冲区,Send
也并不保证消息的完整写入,同样需要我们自行处理。
重复Send:容易想到发送不成功就多发送几次。如果待发送的字节数>0,则再次调用Send,此时注意Send
需要偏移已写入的字节数。
但这种方法有个问题,就是需要保证多次写入的过程中,存储的消息不变。比如在第一次发送不完整,第二次Send还没开始,此时有异步的新消息写入了了缓冲区,那么旧消息的第二次写入就会存在偏移位置的错误。
发送消息队列:因此类似于接收缓冲区的思路,发送方也会创建一个发送消息队列。每次想要发送消息,则将消息入队(队列操作记得加锁),并且另一方面从队列中取出头部元素进行重复Send。
通过队列的存在,其把异步的消息流整合为了同步顺序的消息流,这样保证了每次只发送一条消息,不会被后续消息干扰。
同步
虽然已经实现了网络数据的传输,但由于传输中的延迟、丢包等问题,网络消息到达每一个客户端的时间并不统一、稳定,因此还需要额外的技术来保证客户端之间的同步。
状态同步
状态同步就是数据同步,服务器收集所有人的操作指令,在服务器自己做数据计算,然后向其他客户端广播数据信息,然后进行强行数据处理。这样的缺点在于延迟越大也就会造成越大的瞬移,因此观感很不友好。
为了平滑瞬移的观感,显而易见我们可以使用插值的方法,也就是所谓的 跟随算法:收到目标数据后,用一段时间去插值更新,而不是一步到位。这样虽然视觉上更平滑了,但由于插值更新必然是发生在收到同步数据之后,再花上一段时间去更新,那么必然和真实的客户端A数据存在较大的延迟和误差。
因此为了节省插值时间,我们可以在收到同步数据之前就开始插值,即 预测算法:本机数据先根据本机输入进行响应,其他客户端在本机的数据则保持其原有运动轨迹,自行本地预测,然后在同步数据到来时再进行跟随同步或者瞬移同步。这在稳定运动状态时效果很好,不过在不稳定的运动时容易导致回滚、闪回。示例代码如下:
1 |
|
优点:
- 数据可靠,安全性高
- 断线重连和观战好做:按照数据同步生成一遍就行。
缺点:
- 数据量巨大,进而容易导致延迟大,服务器压力大。
- 回放不好做,需要服务器额外好每帧的战斗数据。
相关优化:
- 延迟补偿:服务器对客户端数据保留快照,在需要确定性计算时根据快照时刻的数据计算。
- 命令缓冲
帧同步
帧同步即指令同步、操作同步,客户端上传指令,服务器广播给其他客户端,所有客户端根据指令在本地模拟逻辑。当然,如果只是简单的逐指令转发,会因为各客户端执行情况的不一样,造成严重的偏离。因此为了统一网络同步时间,引入了 同步帧 的服务器时间概念,来度量诸如 "客户端 A 在第3同步帧发出向前指令,客户端 B 在第5同步帧收到指令" 这样的同步时间描述。
统一时间单位后,我们就能知道是不是发生了指令执行情况的延迟问题。因此一种解决方案是快机等待慢机:A在第3帧发出指令后,自己并不执行。直到其他客户端在第5帧收到指令后,服务器才在第5帧告诉所有客户端开始执行。
优点:
- 数据量小。相比于状态同步会因为同步数据的庞大而导致延迟、阻塞,帧同步的好处在于只需要发送指令,因此网络数据量很小,有助于缓解网络延迟。
- 服务器不需要做逻辑,开发方便
- 回放系统好做:播放操作序列即可。
缺点:
- 数据逻辑在本地端,容易作弊,可以利用投票法防止作弊,因为客户端严格一致,服务器可以收集客户端操作的结果来进行投票表决如"是否击中"的消息。
- 需要等待指令同步。
- 物理和数值问题,不同客户端浮点数、随机数有差异,并且会累积。通常需要改成定点数模拟小数,固定随机数种子。
- 断线重连不好做:需要把累积的帧指令加速模拟。
断线重连
在ping有效期内,重复登录检测后重新发送战斗开始协议和同步协议。
错误排除
在Unity中,只有主线程可以操作UI组件。由于异步回调是在其他线程执行的,不能直接操作unityUI组件。
启动8888端口的服务器后,总发现有一个不知名本地端口会连接上来,然后出现消息解析错误。想了半天代码哪里有问题,结果使用netstat -aon|findstr "8888"排查后发现是迅雷的后台服务和这个端口冲突了...