0%

网络编程基础 (Unity+C#)

基础流程

客户端流程:

  1. socket.Connect(远程IP地址,远程端口)
  2. socket.Send/BenginSend发送数据
  3. socket.Receive/BeginReceive接收服务端数据
  4. 网络操作(如心跳协议)
  5. socket.Close关闭连接

服务器流程:

  1. listenfd.Bind(ipEp)将创建的空套接字listenfd绑定到IP+端口。
  2. listenfd.Listen(backlog)开启监听
  3. listenfd.Accept/BeginAccept接收客户端连接。Accept/EndAccept返回连接的Socket对象,对于服务器来说,它有一个监听Socket(listenfd)用来监听(Listen)和应答(Accept),对每个客户端的连接再创建一个专门的Socket(connfd)用来处理该客户端的数据。
  4. connfd.Receive/BeginReceive 接受数据
  5. 网络操作(如心跳协议)
  6. socket.Close关闭连接

服务器处理socket阻塞

由于服务器需要对接多个客户端,因此服务器不能在一个客户端处阻塞等待,而需要进行特殊的阻塞处理。

异步操作(性能最优):使用Socket的异步API,把阻塞轮询交给内部。异步处理都由BeginXXXAPI进行异步等待开始,并且绑定一个回调函数CustomCallback,这样在异步响应时就会使用回调函数来进行处理。在回调函数内部,首先使用对应的EndXXXAPI来拿到响应数据,对数据处理完成后,可以在回调函数内再次调用BeginXXX,以串行开启新一轮的异步等待。代码示例如下:

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
public static void Connect(string ip, int port)
{
//...
//进行连接准备操作
//...
//使用Begin API来开启异步等待,并绑定回调函数ConnectCallback
socket.BeginConnect(ip, port, ConnectCallback, socket);
}

//Connect回调
private static void ConnectCallback(IAsyncResult ar){
try{
Socket socket = (Socket) ar.AsyncState;
//结束等待 收取响应数据
socket.EndConnect(ar);
//...
//进行一些数据处理操作
//...
//再次调用Begin,开启新一轮的异步等待
socket.BeginReceive( readBuff.bytes, readBuff.writeIdx,
readBuff.remain, 0, ReceiveCallback, socket);

}
catch (SocketException ex){
Debug.Log("Socket Connect fail " + ex.ToString());
}
}

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. 附带长度信息,标识消息完整长度
  2. 附带结束符号,标识消息的结束
  3. 固定长度

最终,附带长度信息后的发送消息格式如下:

1
2
3
4
byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr);//将消息本体 "消息名称|参数1,参数2,..." 转为字节
Int16 len =(Int16)bodyBytes.Length;//利用2字节作为长度信息,以便检查消息的完整性。
byte[] lenBytes = BitConverter.GetBytes(len);//将长度数据转为字节,内部根据大端小端设备分别转换
byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();//拼接完整的发送数据

接收消息同样需要保证接收消息的完整性,因此可以先构造一个接收缓冲区存储socket.Receive的数据,并等待完整消息的到达。

1
2
byte[] readBuff = new byte[1024];
int buffCount = 0;//已接收的字节,新收到的字节则在readBuff[buffCount]处开始写入。

有了缓冲区之后,接收放即可尝试在缓冲区里提取数据:

  1. 缓冲区数据<=2字节,连长度信息都不完整,等待。
  2. 缓冲区数据>2字节,读取头部2字节获取消息长度,接收字节<消息长度,不完整,等待。
  3. 缓冲区数据>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
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
34
35
36

//收到位置同步消息时,顺便计算出预测位置
public void SyncPos(MsgSyncTank msg)
{
//同步位置
Vector3 pos = new Vector3(msg.x, msg.y, msg.z);
Vector3 rot = new Vector3(msg.ex, msg.ey, msg.ez);
//计算匀速运动下的预测位置
forecastPos = pos + 2*(pos - lastPos);
forecastRot = rot + 2*(rot - lastRot);
//跟随算法 强行同步
// forecastPos = pos;
// forecastRot = rot;
//更新
lastPos = pos;
lastRot = rot;
//记录预测时的时间,以便后续插值
forecastTime = Time.time;
}

//SyncPos计算出预测位置之后,update中插值移动到预测位置
public void ForecastUpdate()
{
//插值时间
float t = (Time.time - forecastTime) / CtrlTank.syncInterval;
t = Mathf.Clamp(t, 0f, 1f);
//插值位置
Vector3 pos = transform.position;
pos = Vector3.Lerp(pos, forecastPos, t);
transform.position = pos;
//插值旋转
Quaternion quat = transform.rotation;
Quaternion forcastQuat = Quaternion.Euler(forecastRot);
quat = Quaternion.Lerp(quat, forcastQuat, t);
transform.rotation = quat;
}

优点:

  • 数据可靠,安全性高
  • 断线重连和观战好做:按照数据同步生成一遍就行。

缺点:

  • 数据量巨大,进而容易导致延迟大,服务器压力大。
  • 回放不好做,需要服务器额外好每帧的战斗数据。

相关优化:

  • 延迟补偿:服务器对客户端数据保留快照,在需要确定性计算时根据快照时刻的数据计算。
  • 命令缓冲

帧同步

帧同步即指令同步、操作同步,客户端上传指令,服务器广播给其他客户端,所有客户端根据指令在本地模拟逻辑。当然,如果只是简单的逐指令转发,会因为各客户端执行情况的不一样,造成严重的偏离。因此为了统一网络同步时间,引入了 同步帧 的服务器时间概念,来度量诸如 "客户端 A 在第3同步帧发出向前指令,客户端 B 在第5同步帧收到指令" 这样的同步时间描述。

统一时间单位后,我们就能知道是不是发生了指令执行情况的延迟问题。因此一种解决方案是快机等待慢机:A在第3帧发出指令后,自己并不执行。直到其他客户端在第5帧收到指令后,服务器才在第5帧告诉所有客户端开始执行。

优点:

  • 数据量小。相比于状态同步会因为同步数据的庞大而导致延迟、阻塞,帧同步的好处在于只需要发送指令,因此网络数据量很小,有助于缓解网络延迟。
  • 服务器不需要做逻辑,开发方便
  • 回放系统好做:播放操作序列即可。

缺点:

  • 数据逻辑在本地端,容易作弊,可以利用投票法防止作弊,因为客户端严格一致,服务器可以收集客户端操作的结果来进行投票表决如"是否击中"的消息。
  • 需要等待指令同步。
  • 物理和数值问题,不同客户端浮点数、随机数有差异,并且会累积。通常需要改成定点数模拟小数,固定随机数种子。
  • 断线重连不好做:需要把累积的帧指令加速模拟。

断线重连

在ping有效期内,重复登录检测后重新发送战斗开始协议和同步协议。

错误排除

在Unity中,只有主线程可以操作UI组件。由于异步回调是在其他线程执行的,不能直接操作unityUI组件。

启动8888端口的服务器后,总发现有一个不知名本地端口会连接上来,然后出现消息解析错误。想了半天代码哪里有问题,结果使用netstat -aon|findstr "8888"排查后发现是迅雷的后台服务和这个端口冲突了...

参考资料

Unity3D网络游戏实战(第2版)