Unity 网络编程-正确收发数据流
1.TCP数据流
我们知道在使用Socket网络程序时,操作系统会将数据存到发送接收缓存中。程序不能直接操作它们,只能通过socket.Receive, socket.Send等方法来间接操作。
在使用以上方法时,如果接收缓存为空,那Receive方法会阻塞。如果发送缓存满了则Send方法会阻塞。
粘包半包现象
如果发送端快速发送多跳信息,但是接收端没有及时的调用Receive,那数据就会在接收缓存中累计。
粘包:假设发送队列中一开始发送 {1,2,3,4}这四个字节的数据,然后发送{5,6,7,8}。等到服务端调用Receive时只调用了一次,那么此时的接收缓存就会变成{1,2,3,4,5,6,7,8}。
半包/分包: 假设发送{HelloWorld},但是接收队列可能要接收两次,分别接收{Hel},{loWorld}。
由于TCP是基于流发送的,所以以上现象是很正常的现象,但是我们不想要这样,直觉告诉我们我们应该发一个包就收一个包才不会弄混淆数据。
2.解决粘包问题
一般有三种方法可以解决该问题:长度信息法,固定长度法,结束符法。
1.长度信息法
长度信息法的意思是在每个数据包之前加上长度信息,每次接收数据包后先读取表示长度的字节。然后取出响应的字节,否则继续等待数据接受。
2.固定长度法
每次都以相同的长度发送数据。。例如发送{Hello}{World},发送长度为5。如果接收到{Hello...Wo},...Wo就会被存起来等待下次接收数据。而又因为...为填充符,所以舍弃。即只保留Wo等待下一个包再拼接在一起。
3.结束符法
规定一个结束字符,用来分割消息。例如$ ,发送{Hello$}{World$}。接收到$为止,有多余的数据等到下一次接收后再拼接。
实现:游戏一般会使用长度信息法用于解决该问题
发送: 假设要发送{HelloWorld},实际发送变成{0AHelloWorld},0A表示长度10。
public void Send(string sendStr){ // 组装协议 byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr); Int16 len = (Int16)bodyBytes.Length; byte[] lenBytes = BitConverter.GetBytes(len); byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray(); // 简洁代码同步发送 socket.Send(sendBytes) }
接收:接收的情况比较复杂,首先应该定义一个接收缓存区(readBuff)和缓存区有效数据长度(buffCount)。
原因是有可能会出现粘包问题,例如接收到{HelloWo},这是有效数据长度就是7。这样我们既可以处理分包问题,用7减去长度5。或者是解决分包问题,下一次接收数据从7开始接收。
对于缓存区的长度也有一下问题:
1.缓冲区长度小于等于2(2字节代表长度位):消息太短就不处理,直接return。
2.缓冲区长度大于2,但还不足以构成一条消息:如果不足以构成完整的信息,那就等待下一次接收。
3.缓冲区大于等于一条长度:那就解析出消息,然后让后面的字节往前移动。
public void OnReceiveData(){ //消息长度 if(buffCount <=2) return; Int16 bodyLength = BitConverter.ToInt16(readBuff,0); //消息体 if(buffCount <2+bodyLength) return; string s = System.Text.Encoding.UTF8.GetString(readBuff,2,buffCount); // s 消息内容 // 更新缓冲区 int start = 2 +bodyLength; int count = buffCount - start; Array.Copy(readBuff,start,readBuff,0,count); buffCount -=start; // 继续读取消息 if(readBuff.length>2){ OnReceiveData(); } }
3.大端小端问题
我们在前面解决粘包分包问题,使用,在计算数据长度时我们使用的是BitConvert.ToInit16。
而.Net中该方法的底层简化为如下:
public static short ToInt16(byte[] value,int startindex){ if(startIndex%2 ==0){ return *((short*)pbyte); }else{ if(IsLittleEndian) return (short)((*pbyte)|(*(pbyte+1)<<8)); } .... }
其中,IsLittleEndian代表计算机是大端编码还是小端编码。不同的编码方式也会不同。所以使用该方法计算出来的消息长度也会不同。
解决:为了兼容所有的设备,我们一般规定写入的数字必须按照小端的模式来存储。
public void Send(string sendStr){ byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr); Int16 len = (Int16)bodyBytes.Length; byte[] lenBytes = BitConverter.GetBytes(len); // 大端小端编码 if(!BitConverter.IsLittleEndian){ Debug.Log("Change") lenBytes = lenBytes.Reverse(); } byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray(); socket.Send(sendBytes) }
4.发送完整数据
在Send方法中,会把发送的数据存入操作系统的发送缓冲区,然后返回成功写入的字节数。即对于那些没有成功发送的数据,程序需要保存起来,再适当的时机再次发送。在大部分情况下,Send发送部分数据的情况并不是很多,但是以防万一,我们也需要对这种情况处理。
为了让数据能够发送完整,需要在发送前将数据保存起来;如果发送不完整,在Send回调函数中继续发送数据。
byte[] sendBytes = new byte[2014]; // 缓冲区偏移量 int readIdx =0; //缓冲区剩余长度 int length =0; //点击发送数据 public void Send(){ sendBytes = 数据 length = sendByte.Length; readIdx = 0; socket.BeginSend(sendBytes,0,length,0,SendCallback,socket); // } public void SendCallback(IAsyncResult ar){ Socket socket = (Socket)ar.AsyncState; int count = socket.EndSend(ar); readIdx +=count; length -= count; if(length>0){ socket.BeginSend(sendBytes,readIdx,length,0,SendCallback,socket); // } }
以上的方式,只解决了一半的问题,因为调用BeginSend之后,可能要隔一段时间才能调用回调函数SendCallback。此时,如果玩家在回调函数调用前再次点击发送按钮,按照这里的写法,readIdx和length都会被重置,那SendCallback可能就不能再继续工作。所以我们要解决这个问题就要设计一个加强版的缓冲区,叫做写入队列。(队列的写入操作是O(1),如果使用大数组实现性能也没有队列高)
即采用一个队列的形式存放写入缓存。当回调函数返回成功时才会将一个缓存推出队列。
数据结构定义如下:
public class ByteArray { public byte[] bytes; public int readIdx =0; public int writeIdx =0; public int length{ get{return writeIdx-readIdx; } }; public ByteArray(byte[] defaultArray){ bytes = defaultBytes; readIdx = 0; writeIdx = defaultArray.Length; } }
线程冲突问题:通过异步的机制我们可以知道,BeginSend和回调不在一个线程上,那就有可能会发生线程冲突的问题。要解决该问题也很简单,我们可以通过加锁(Lock)的方式解决。使用时注意把临界区设置的尽可能小,以提高性能。
5.高效的接收数据
在之前的代码中,我们接收数据使用了Copy函数,这个函数的时间复杂度是On。加入缓冲区的数据很多,那移动全部数据会花费比较长的时间。
可行的解决办法:使用ByteArray作为缓冲区,当读取数据结束时只用移动readIdx。当缓冲区长度不够时才会再使用Array.Copy重置readIdx和writeIdx。同时还需要为缓冲区设置自动扩展的功能,以防网络堵塞导致缓冲区满。
为满足上述,数据结构如下:
public class ByteArray {
// 默认大小 const int DEFAULT_SIZE = 1024; // 初始大小 int initSize = 0; // 缓冲区 public byte[] bytes; // 读写位置 public int readIdx =0; public int writeIdx =0; // 容量 private int capacity = 0; // 剩余空间 public int remain { get{ return capacity - writeIdx}} // 数据长度 public int length{ get{return writeIdx-readIdx; } }; // 构造函数 public ByteArray(int size = DEFAULT_SIZE){ bytes = new bytes[size]; capacity = size; initSize = size; readIdx = 0; writeIdx = defaultArray.Length; } // 重写 构造函数 public ByteArray(byte[] defaultBytes){ bytes = defaultBytes; capacity = defaultBytes.Length; initSize = defaultBytes.Length; readIdx = 0; writeIdx = defaultArray.Length; } // 重设尺寸 public void Resize(int size){ if(size<length) return; if(size<initSize) return; int n =1; while(n<size) n*=2; capacity = n; byte[] newBytes = new byte[capacity]; Array.Copy(bytes,readIdx,newBytes,0,writeIdx-readIdx); bytes = newBytes; writeIdx = length; readIdx =0; } }