一文告诉你什么是 TCP 数据粘包,该如何解决!

粘包问题概述

描述背景

采用TCP协议进行网络数据传送的软件设计中,普遍存在粘包问题。这主要是由于现代操作系统的网络传输机制所产生的。

我们知道,网络通信采用的套接字(socket)技术,其实现实际是由系统内核提供一片连续缓存(流缓冲)来实现应用层程序与网卡接口之间的中转功能。

多个数据包被连续存储于连续的缓存中,在对数据包进行读取时由于无法确定发送方的发送边界,而采用某一估测值大小来进行数据读出,若双方的size不一致时就会使数据包的边界发生错位,导致读出错误的数据分包,进而曲解原始数据含义。

粘包的概念

粘包问题的本质就是数据读取边界错误所致,通过下图可以形象地理解其现象。

如图1所示,当前的socket缓存中已经有6个数据分组到达,其大小如图中数字。而应用程序在对数据进行收取时(如图2),采用了300字节的要求去读取,则会误将pkg1和pkg2一起收走当做一个包来处理。

而实际上,很可能pkg1是一个文本文件的内容,而pkg2则可能是一个音频内容,这风马牛不相及的两个数据包却被揉进一个包进行处理,显然有失妥当。严重时可能因为丢了pkg2而导致软件陷入异常分支产生乌龙事件。

因此,粘包问题必须引起所有软件设计者(项目经理)的高度重视!

那么,或许会有读者发问,为何不让接收程序按照100字节来读取呢?我想如果你了解一些TCP编程的话就不会有这样的问题。

网络通信程序中,数据包通常是不能确定大小的,尤其在软件设计阶段无法真的做到确定为一个固定值。比如聊天软件客户端若采用TCP传输一个用户名和密码到服务端进行验证登陆,我想这个数据包不过是几十字节,至多几百字节即可发送完毕,而有时候要传输一个很大的视频文件,即使分包发送也应该一个包在几千字节吧。(据说,某国电信平台的MW中见到过一次发送1.5万字节的电话数据)。

这种情况下,发送数据的分包大小无法固定,接收端也就无法固定。所以一般采用一个较为合理的预估值进行轮询接收。(网卡的MTU都是1500字节,因此这个预估值一般为MTU的1~3倍)。

相信读者对粘包问题应该有了初步认识了。

粘包回避设计

一:定长发送

在进行数据发送时采用固定长度的设计。也就是无论发送多大数据都分包为固定长度(为便于描述,此处定长为记为LEN),即发送端在发送数据时都以LEN为长度进行分包。

这样接收方都以固定的LEN进行接收,如此一来发送和接收就能一一对应了。分包的时候不一定能完整的恰好分成多个完整的LEN的包,最后一个包一般都会小于LEN,这时候最后一个包可以在不足的部分填充空白字节。

当然,这种方法会有缺陷:

1. 最后一个包的不足长度被填充为空白部分,也即无效字节序。那么接收方可能难以辨别这无效的部分,它本身就是为了补位的,并无实际含义。这就为接收端处理其含义带来了麻烦。当然也有解决办法,可以通过增添标志位的方法来弥补,即在每一个数据包的最前面增加一个定长的报头,然后将该数据包的末尾标记一并发送。接收方根据这个标记确认无效字节序列,从而实现数据的完整接收。

2. 在发送包长度随机分布的情况下,会造成带宽浪费。比如发送长度可能为 1,100,1000,4000字节等等,则都需要按照定长最大值即4000来发送,数据包小于4000字节的其他包也会被填充至4000,造成网络负载的无效浪费。

综上,此方案适在发送数据包长度较为稳定(趋于某一固定值)的情况下有较好的效果。

二:尾部标记序列

在每个要发送的数据包的尾部设置一个特殊的字节序列,此序列带有特殊含义,跟字符串的结束符标识”\0”一样的含义,用来标示这个数据包的末尾,接收方可对接收的数据进行分析,通过尾部序列确认数据包的边界。

这种方法的缺陷较为明显:

1. 接收方需要对数据进行分析,甄别尾部序列。

2. 尾部序列的确定本身是一个问题。什么样的序列可以向”\0”一样来做一个结束符呢?这个序列必须是不具备通常任何人类或者程序可识别的带含义的数据序列,就像“\0”是一个无效字符串内容,因而可以作为字符串的结束标记。那普通的网络通信中,这个序列是什么呢?我想一时间很难找到恰当的答案。

三:头部标记分步接收

这个方法是作者有限学识里最好的办法了。它既不损失效率,还完美解决了任何大小的数据包的边界问题。

这个方法的实现是这样的:

1. 定义一个用户报头,在报头中注明每次发送的数据包大小。

2. 接收方每次接收时先以报头的size进行数据读取,这必然只能读到一个报头的数据,从报头中得到该数据包的数据大小。

3. 再按照此大小进行再次读取,就能读到数据的内容了。

这样一来,每个数据包发送时都封装一个报头,然后接收方分两次接收一个包,第一次接收报头,根据报头大小第二次才接收数据内容。

(此处的data[0]的本质是一个指针,指向数据的正文部分,也可以是一篇连续数据区的起始位置。因此可以设计成data[user_size],这样的话。)

下面通过一个图来展现设计思想。

由图看出,数据发送多了封装报头的动作;接收方将每个包的接收拆分成了两次。

这方案看似精妙,实则也有缺陷:

1. 报头虽小,但每个包都需要多封装sizeof(_data_head)的数据,积累效应也不可完全忽略。

2. 接收方的接收动作分成了两次,也就是进行数据读取的操作被增加了一倍,而数据读取操作的recv或者read都是系统调用,这对内核而言的开销是一个不能完全忽略的影响,对程序而言性能影响可忽略(系统调用的速度非常快)。

优点:避免了程序设计的复杂性,其有效性便于验证,对软件设计的稳定性要求来说更容易达标。

 资料直通车:Linux内核源码技术学习路线+视频教程内核源码

学习直通车:Linux内核源码内存调优文件系统进程管理设备驱动/网络协议栈

补充

何时需要考虑粘包问题?

1、如果利用tcp每次发送数据,就与对方建立连接,然后双方发送完一段数据后,就关闭连接,这样就不会出现粘包问题(因为只有一种包结构,类似于http协议)。

关闭连接主要要双方都发送close连接(参考tcp关闭协议)。如:A需要发送一段字符串给B,那么A与B建立连接,然后发送双方都默认好的协议字符如"hello give me sth abour yourself",然后B收到报文后,就将缓冲区数据接收,然后关闭连接,这样粘包问题不用考虑到,因为大家都知道是发送一段字符。

2、如果发送数据无结构,如文件传输,这样发送方只管发送,接收方只管接收存储就ok,也不用考虑粘包。

3、如果双方建立连接,需要在连接后一段时间内发送不同结构数据,如连接后,有好几种结构:

1)"hello give me sth abour yourself"

2)"Don't give me sth abour yourself"

那这样的话,如果发送方连续发送这个两个包出去,接收方一次接收可能会是"hello give me sth abour yourselfDon't give me sth abour yourself" 。

这样接收方就傻了,到底是要干嘛?不知道,因为协议没有规定这么诡异的字符串,所以要处理把它分包,怎么分也需要双方组织一个比较好的包结构,所以一般可能会在头加一个数据长度之类的包,以确保接收。

粘包出现的原因:在流传输中出现,UDP不会出现粘包,因为它有消息边界。

1 发送端需要等缓冲区满才发送出去,造成粘包 ;

2 接收方不及时接收缓冲区的包,造成多个包接收;

解决办法:

为了避免粘包现象,可采取以下几种措施。

一是对于发送方引起的粘包现象,用户可通过编程设置来避免,TCP提供了强制数据立即传送的操作指令push,TCP软件收到该操作指令后,就立即将本段数据发送出去,而不必等待发送缓冲区满;

二是对于接收方引起的粘包,则可通过优化程序设计、精简接收进程工作量、提高接收进程优先级等措施,使其及时接收数据,从而尽量避免出现粘包现象;

三是由接收方控制,将一包数据按结构字段,人为控制分多次接收,然后合并,通过这种手段来避免粘包。

以上提到的三种措施,都有其不足之处。

第一种编程设置方法虽然可以避免发送方引起的粘包,但它关闭了优化算法,降低了网络发送效率,影响应用程序的性能,一般不建议使用。

第二种方法只能减少出现粘包的可能性,但并不能完全避免粘包,当发送频率较高时,或由于网络突发可能使某个时间段数据包到达接收方较快,接收方还是有可能来不及接收,从而导致粘包。

第三种方法虽然避免了粘包,但应用程序的效率较低,对实时应用的场合不适合。

为什么基于TCP的通讯程序需要进行封包和拆包

TCP是个"流"协议,所谓流,就是没有界限的一串数据.大家可以想想河里的流水,是连成一片的,其间是没有分界线的.但一般通讯程序开发是需要定义一个个相互独立的数据包的,比如用于登陆的数据包,用于注销的数据包.由于TCP"流"的特性以及网络状况,在进行数据传输时会出现以下几种情况.

假设我们连续调用两次send分别发送两段数据data1和data2,在接收端有以下几种接收情况(当然不止这几种情况,这里只列出了有代表性的情况).

A. 先接收到data1,然后接收到data2。

B. 先接收到data1的部分数据,然后接收到data1余下的部分以及data2的全部。

C. 先接收到了data1的全部数据和data2的部分数据,然后接收到了data2的余下的数据。

D. 一次性接收到了data1和data2的全部数据。

对于A这种情况正是我们需要的,不再做讨论。对于B,C,D的情况就是大家经常说的"粘包",就需要我们把接收到的数据进行拆包,拆成一个个独立的数据包。为了拆包就必须在发送端进行封包。

另:对于UDP来说就不存在拆包的问题,因为UDP是个"数据包"协议,也就是两段数据间是有界限的,在接收端要么接收不到数据,要么就是接收一个完整的一段数据,不会少接收也不会多接收。

为什么会出现B.C.D的情况

"粘包"可发生在发送端也可发生在接收端。

1. 由Nagle算法造成的发送端的粘包:

Nagle算法是一种改善网络传输效率的算法。简单的说,当我们提交一段数据给TCP发送时,TCP并不立刻发送此段数据,而是等待一小段时间,看看在等待期间是否还有要发送的数据,若有则会一次把这两段数据发送出去。

这是对Nagle算法一个简单的解释,详细的请看相关书籍。像C和D的情况就有可能是Nagle算法造成的.

2. 接收端接收不及时造成的接收端粘包:

TCP会把接收到的数据存在自己的缓冲区中,然后通知应用层取数据。当应用层由于某些原因不能及时的把TCP的数据取出来,就会造成TCP缓冲区中存放了几段数据。

怎样封包和拆包

最初遇到"粘包"的问题时,我是通过在两次send之间调用 sleep 来休眠一小段时间来解决。

这个解决方法的缺点是显而易见的,使传输效率大大降低,而且也并不可靠。后来就是通过应答的方式来解决,尽管在大多数时候是可行的,但是不能解决象B的那种情况,而且采用应答方式增加了通讯量,加重了网络负荷。再后来就是对数据包进行封包和拆包的操作。

封包就是给一段数据加上包头,这样一来数据包就分为包头和包体两部分内容了(以后讲过滤非法包时封包会加入"包尾"内容)。

包头其实上是个大小固定的结构体,其中有个结构体成员变量表示包体的长度,这是个很重要的变量,其他的结构体成员可根据需要自己定义。根据包头长度固定以及包头中含有包体长度的变量就能正确的拆分出一个完整的数据包。

对于拆包,目前我最常用的是以下两种方式。

1. 动态缓冲区暂存方式。之所以说缓冲区是动态的,是因为当需要缓冲的数据长度超出缓冲区的长度时会增大缓冲区长度。大概过程描述如下:

A, 为每一个连接动态分配一个缓冲区,同时把此缓冲区和SOCKET关联,常用的是通过结构体关联.B, 当接收到数据时首先把此段数据存放在缓冲区中. C, 判断缓存区中的数据长度是否够一个包头的长度,如不够,则不进行拆包操作.D, 根据包头数据解析出里面代表包体长度的变量. E, 判断缓存区中除包头外的数据长度是否够一个包体的长度,如不够,则不进行拆包操作. F ,取出整个数据包。这里的"取"的意思是不光从缓冲区中拷贝出数据包,而且要把此数据包从缓存区中删除掉.删除的办法就是把此包后面的数据移动到缓冲区的起始地址。

这种方法有两个缺点:1. 为每个连接动态分配一个缓冲区增大了内存的使用。2.有三个地方需要拷贝数据,一个地方是把数据存放在缓冲区,一个地方是把完整的数据包从缓冲区取出来,一个地方是把数据包从缓冲区中删除。

前面提到过这种方法的缺点。下面给出一个改进办法, 即采用环形缓冲.但是这种改进方法还是不能解决第一个缺点以及第一个数据拷贝,只能解决第三个地方的数据拷贝(这个地方是拷贝数据最多的地方). 第2种拆包方式会解决这两个问题.

环形缓冲实现方案是定义两个指针,分别指向有效数据的头和尾。在存放数据和删除数据时只是进行头尾指针的移动。

2. 利用底层的缓冲区来进行拆包

由于TCP也维护了一个缓冲区,所以我们完全可以利用TCP的缓冲区来缓存我们的数据,这样一来就不需要为每一个连接分配一个缓冲区了。另一方面我们知道recv或者wsarecv都有一个参数,用来表示我们要接收多长长度的数据。利用这两个条件我们就可以对第一种方法进行优化。

对于阻塞SOCKET来说,我们可以利用一个循环来接收包头长度的数据,然后解析出代表包体长度的那个变量,再用一个循环来接收包体长度的数据。相关代码如下:

char PackageHead[1024];
char PackageContext[1024*20];

int len;
PACKAGE_HEAD *pPackageHead;
while( m_bClose == false )
{
    memset(PackageHead,0,sizeof(PACKAGE_HEAD));
    len = m_TcpSock.ReceiveSize((char*)PackageHead,sizeof(PACKAGE_HEAD));
    if( len == SOCKET_ERROR )
    {
        break;
    }
    if(len == 0)
   {
      break;
   }
    pPackageHead = (PACKAGE_HEAD *)PackageHead;
    memset(PackageContext,0,sizeof(PackageContext));
    if(pPackageHead->nDataLen>0)
    {
    len = m_TcpSock.ReceiveSize((char*)PackageContext,pPackageHead->nDataLen);
    }
 }

m_TcpSock是一个封装了SOCKET的类的变量,其中的ReceiveSize用于接收一定长度的数据,直到接收了一定长度的数据或者网络出错才返回。

int winSocket::ReceiveSize( char* strData, int iLen )
{
    if( strData == NULL )
        return ERR_BADPARAM;
    char *p = strData;
    int len = iLen;
    int ret = 0;
    int returnlen = 0;
    while( len > 0)
    {
        ret = recv( m_hSocket, p+(iLen-len), iLen-returnlen, 0 );
        if ( ret == SOCKET_ERROR || ret == 0 )
        {
            return ret;
        }
        len -= ret;
        returnlen += ret;
    }
    return returnlen;
}

对于非阻塞的SOCKET,比如完成端口,我们可以提交接收包头长度的数据的请求。当 GetQueuedCompletionStatus 返回时,我们判断接收的数据长度是否等于包头长度,若等于,则提交接收包体长度的数据的请求;若不等于,则提交接收剩余数据的请求。当接收包体时,采用类似的方法。

原文作者: 一起学嵌入式

猜你喜欢

转载自blog.csdn.net/youzhangjing_/article/details/132832826