网络游戏开发基础(三)TCP协议可能出现的问题

目录

  • 一、分包粘包问题
    • 1、长度信息法
    • 2、固定长度法
    • 3、结束符号法
  • 二、大端小端问题
    • 1、使用Reverse()兼容大小端编码
    • 2、手动还原数值
  • 三、发送数据不完整
    • 1、解决发送不完整问题
    • 2、写入队列
  • 四、线程冲突
    • 1、使用线程锁
  • 五、总结
  • 参考内容

一、分包粘包问题

如果发送端快速发送多条数据,接收端没有及时调用Receive,那么数据便会在接收端的缓冲区中累积。如图所示,客户端先发送“1、2、3、4”四个字节的数据,紧接着又发送“5、6、7、8”四个字节的数据。等到服务端调用Rec eive时,服务端操作系统已经将接收到的数据全部写入缓冲区,共接收到8个数据。
网络游戏开发基础(三)TCP协议可能出现的问题_第1张图片

1、长度信息法

长度信息法是指在每个数据包前面加上长度信息。每次接收到数据后,先读取表示长度的字节,如果缓冲区的数据长度大于要取的字节数,则取出相应的字节,否则等待下一次数据接收。

2、固定长度法

每次都以相同的长度发送数据,假设规定每条信息的长度都为10个字符,那么发送“Hello”“Unity”两条信息可以发送成“He llo… ”“Unity… ”,其中的“. ”表示填充字符,是为凑数,没有实际意义,只为了每次发送的数据都有固定长度。接收方每次读取10个字符,作为一条消息去处理。如果读到的字符数大于10,比如第1次读到“He llo…Un”,那它只要把前10个字节“Hello… ”抽取出来,再把后面的两个字节“Un”存起来,等到再次接收数据,拼接第二条信息。

3、结束符号法

规定一个结束符号,作为消息间的分隔符。假设规定结束符号为“ ”,那么发送“ H e l l o ”“ U n i t y ”两条信息可以发送成“ H e l l o ”,那么发送“Hello”“Unity”两条信息可以发送成“Hello ,那么发送Hello”“Unity两条信息可以发送成Hello”“ Unity ”。接收方每次读取数据,直到“ ”。接收方每次读取数据,直到“ 。接收方每次读取数据,直到”出现为止,并且使用“ ”去分割消息。比如接收方第一次读到“ H e l l o ”去分割消息。比如接收方第一次读到“Hel lo 去分割消息。比如接收方第一次读到HelloUn”,那它把结束符前面的Hello提取出来,作为第一条消息去处理,再把“Un”保存起来。待后续读到“ity$”,再把“Un”和“ity”拼成第二条消息。

二、大端小端问题

由于历史遗留问题,字节顺序与存储地址顺序有两种模式
大端:低地址存放高位字节
小端:低地址存放低位字节
例如:数据0x1234,两种模式都是从低地址开始写入数据,高位指的是左边的位数,例如数字123,1是高位,3是地位

类型 低地址 高地址
大端模式 0x12 0x34
小端模式 0x34 0x12

那么问题就是,不同编码方式下,计算方法不同,那对于不同的计算机,读取出来的数据长度有可能不同,为了统一格式,所有收发数字都采用小端模式

1、使用Reverse()兼容大小端编码

如果使用BitConverter.GetBytes将数字转换成二进制数据,转换出来的数据有可能基于大端模式,也有可能基于小端模式。因为我们规定必须使用小端编码,一个简单的办法是,判断系统是否是小端编码的系统,如果不是,就使用Reverse()方法将大端编码转换为小端编码。以Send为例,代码如下:

        //点击发送按钮
        public void Send()
        {
            string sendStr = InputFeld.text;
            //组装协议
            byte[] bodyBytes = System.Text.Encoding.Default.GetBytes(sendStr);
            Int16 len = (Int16)bodyBytes.Length;
            byte[] lenBytes = BitConverter.GetBytes(len);
            //大小端编码
            if(! BitConverter.IsLittleEndian){
                Debug.Log("[Send] Reverse lenBytes");
                lenBytes.Reverse();
            }
            //拼接字节
            byte[] sendBytes = lenBytes.Concat(bodyBytes).ToArray();
            socket.Send(sendBytes);
        }

2、手动还原数值

BitConverter.ToInt16中根据系统大小端采用不同的编码方式,如果是小端编码,返回的是(pbyte) | ((pbyte + 1) << 8),如果是大端编码,返回的是(pbyte <<8) | ((pbyte +1))。在下面的代码中,readBuff[0]代表缓冲区的第1个字节,readBuff[1]代表缓冲区的第2个字节,(readBuff[1] << 8)代表将缓冲区第2个字节的数据乘以256,中间的“|”代表逻辑与,在这里等同于相加。

        public void OnReceiveData(){
            //消息长度
            if(buffCount <= 2)
                return;
            //消息长度
            Int16 bodyLength = (short)((readBuff[1] << 8) | readBuff[0]);
            Debug.Log("[Recv] bodyLength=" + bodyLength);
            //消息体、更新缓冲区
            //消息处理、继续读取消息
            ……
        }

三、发送数据不完整

假设缓冲区设置得很小,并且网络出现问题,导致Send缓冲区的数据没有发出去,此时又向缓冲区写入数据,当网络恢复时,就会将两次存入的数据一起发出去。第二次写入的数据可能不完整,导致接收方无法解析

1、解决发送不完整问题

要让数据能够发送完整,需要在发送前将数据保存起来;如果发送不完整,在Send回调函数中继续发送数据

        //定义发送缓冲区
        byte[] sendBytes = new byte[1024];
        //缓冲区偏移值
        int readIdx = 0;
        //缓冲区剩余长度
        int length = 0;

        //点击发送按钮
        public void Send()
        {
            sendBytes = 要发送的数据;
            length = sendBytes.Length;       //数据长度
            readIdx = 0;
            socket.BeginSend(sendBytes, 0, length, 0, SendCallback, socket);
        }

        //Send回调
        public void SendCallback(IAsyncResult ar){
            //获取state
            Socket socket = (Socket) ar.AsyncState;
            //EndSend的处理
            int count = socket.EndSend(ar);
            readIdx + =count;
            length -= count;
            //继续发送
            if(length > 0){
                socket.BeginSend(sendBytes,
                    readIdx,  length, 0, SendCallback, socket);
            }
        }

2、写入队列

上面的方案解决了一半问题,因为调用BeginSend之后,可能要隔一段时间才会调用回调函数,如果玩家在SendCallback被调用之前再次点击发送按钮,按照前面的写法,会重置readIdx和length, SendCallback也就不可能正确工作了。为此我们设计了加强版的发送缓冲区,叫作写入队列(writeQueue)
网络游戏开发基础(三)TCP协议可能出现的问题_第2张图片

        public void Send() {
            sendBytes = 要发送的数据;
            writeQueue.Enqueue(ba);     //假设ba封装了readbuff、readIdx、length等数据
            if(writeQueue只有一条数据){
                socket.BeginSend(参数略);
            }
        }

        public void SendCallback(IAsyncResult ar){
            count = socket.EndSend(ar);
            ByteArray ba = writeQueue.First();  //ByteArray后面再介绍
            ba.readIdx+=count;  //length的处理略
            if(发送不完整){
                取出第一条数据,再次发送
            }
            else if(发送完整,且writeQueue还有数据){
                删除第一条数据
                取出第二条数据,如有,发送
            }
        }

每次Send数据以后将数据加入写入队列,判断写入队列只有一条数据时才发送,这样就能保证每次只处理一条数据,避免混乱。

四、线程冲突

由异步的机制可以知道,BeginSend和回调函数往往执行于不同的线程,如果多个线程同时操作writeQueue,有可能引发些问题。在下图所示的流程中,玩家连续点击两次发送按钮,假如运气特别差,第二次发送时,第一次发送的回调函数刚好被调用。如果线程1的Send刚好走到writeQueue.Enqueue(ba)这一行(t2时刻),按理说writeQueue.Count应为2,不应该进入if(writeQueue.Count == 1)的真分支去发送数据(因为此时writeQueue.Count== 2)。但假如在条件判断之前,回调线程刚好执行了writeQueue.Dequeue()(t3时刻),由于writeQueue里只有1个元素,在t4时刻主线程判断if(writeQueue.Count == 1)时,条件成立,会发送数据。但SendCallback中ba = writeQueue.First()也会获取到队列的第一条数据,也会把它发送出去。第二次发送的数据将会被发送两次,显然不是我们需要的。
在这里插入图片描述
网络游戏开发基础(三)TCP协议可能出现的问题_第3张图片

1、使用线程锁

在对写入队列进行操作的时候加一个互斥锁,能保证每次只有一个线程执行该语句

lock(writeQueue){
    writeQueue.Enqueue(ba);
    count = writeQueue.Count;
}

五、总结

我这里只是把原书的内容进行了一个概括,方便快速查漏补缺,详细内容请去看原书和查阅相关资料

参考内容

Unity3D网络游戏实战(第2版)-罗培羽

你可能感兴趣的:(tcp/ip,网络协议,网络)