一、前言
我们知道,TCP是一个面向字节流的传输层协议。“流” 意味着 TCP 所传输的数据是没有边界的。这不同于 UDP 协议提供的是面向消息的传输服务,其传输的数据是有边界的。TCP 的发送方无法保证对方每次收到的都是一个完整的数据包。于是就有了粘包、拆包问题的出现。粘包、拆包问题只发生在TCP协议中。
二、什么是粘包、拆包?
假设客户端向服务器连续发送了两个数据包,用 packet1 和 packet2 来表示,那么服务端收到的数据可以分为下面三种情况:
- 第一种情况,接收端正常收到两个数据包,即没有发生拆包和粘包的现象,此种情况不在本文的讨论范围内。
- 第二种情况,接收端只收到一个TCP报文段,去掉首部后,这一个报文段中包含了发送端发送来的两个数据包的信息,这种现象即为粘包。这种情况由于接收端不知道这两个数据包的界限,所以对于接收端来说很难处理。
- 第三种情况,这种情况有两种表现形式,如下图。接收端收到了两个TCP报文段,但是去掉首部后,两个数据包要么是不完整的,要么就是多出来一部分,这种现象即为粘包、拆包问题。同第二种情况一样,由于不知道两个数据包的界限,对于接收端来说同样不好处理。
综上所述,我们可以得出结论:
TCP粘包是指发送方发送的若干数据包到达接收方时粘成一个数据包,从接收缓存看,后一个数据包的头紧接着前一个数据包的尾。
TCP拆包是指应用程序的数据包被拆分成若干部分发送出去,从接收缓存看,接收方收到的只是数据包的一部分内容。
由此可见,粘包情况有两种,一种是粘在一起的包都是完整的数据包,另一种情况是粘在一起的包有不完整的数据包。
三、粘包产生原因及解决办法
3.1 TCP粘包产生原因
TCP 发生粘包、拆包问题,主要是由于以下几个原因:
(1)应用进程写入的数据量大于TCP发送缓冲区的大小,这将会发生拆包。应用进程调用write()系统调用,将应用层的缓冲区中的数据拷贝到TCP socket 的发送缓冲区。而TCP的发送缓冲区有一个 SO_SNDBUF 的大小限制,如果应用层的缓冲区数据大小大于TCP发送缓冲区的大小,则数据包需要进行拆包处理,分多次进行发送。
(2)应用进程写入的数据量小于TCP发送缓冲区的大小,这将会发生粘包。应用进程调用write()系统调用,将应用层的缓冲区中的数据拷贝到TCP socket 的发送缓冲区。由于TCP发送缓存空间足够,它会等到有多个数据包时,再组装成一个TCP报文段,然后通过网卡发送到网络中去。
TCP协议允许发送端将几次发送的数据包先缓存起来合成一个数据包发送到网络上去,因为这样可以获得更高的效率,这一行为通常是在操作系统提供的
socket
中实现,所以在应用层对此毫无所觉。所以我们在程序中调用socket
的send
()函数,发送了数据后操作系统有可能是先缓存了起来,等待后续的数据。当socket的发送缓存的数据足够多时,再一起发送出去,而不是立即发送出去。这就是缓存发送,它可以提高网络信道的利用率。
(3)当应用进程发送的数据包大于 MSS(最大报文段长度)时,将会发生拆包。TCP在传送数据时,是以报文段为单位发送数据的,而待发送的数据是以 MSS 为单位进行分割的。如果应用进程发送的数据包packet的长度大于 MSS,必然需要对该数据包进行拆包处理。
<注1> MSS(Maximum Segment Size,最大报文段长度) 这个名词的含义是:每一个 TCP 报文段中的数据部分的最大长度。它不是指整个TCP报文段的最大长度,而是 “TCP报文段的长度 - TCP首部长度”。TCP 在组装报文段时,是以 MSS 为单位对需要传送的数据进行分割的,然后加上TCP首部后,就组装成一个完整的TCP报文段了。MSS的默认值一般是 536 字节长。
【补充说明】TCP 之所以有一个 MSS 的限定,是因为在数据链路层的数据传输单位—数据帧,它有一个最大传送长度的限制,即 MTU。要求发送的帧的数据部分长度不超过 MTU,如果超过了就需要进行分包发送处理。
MTU(Maximum Transfer Unit,最大传送单元) 这个名词的含义是:数据链路层协议中规定的所能传送的帧的数据部分长度上限。
<注2> TCP报文段 = TCP首部 + TCP数据部分
(4) 接收方不及时读取接收缓冲区中的数据,将会发生粘包。这是因为接收方先把接收到的数据存放在内核接收缓冲区中,用户进程从接收缓冲区读取数据,若下一个数据包到达时前一个数据包尚未被用户进程取走,则下一个数据包放到内核接收缓冲区时就和前一数据包粘在一起,而用户进程根据预先设定的缓冲区大小从内核接收缓冲区读取数据,这样就一次性读取到了多个数据包。
以上这几种情况,都会导致一个完整的应用层数据包被分割成多片或者是多个应用层数据包拼接成一个更大的传输层数据包,再发送出去,从而导致接收方不是按完整的应用层数据包方式来接收数据的。
3.2 什么时候需要考虑粘包问题?
TCP是长连接,并且传输的是结构化数据时,如:传送的是一个结构体类型的数据,由于不知道结构化数据的边界,容易导致粘包问题的出现。这时需要考虑粘包问题的影响。
什么时候不需要考虑粘包问题?
(1)如果 TCP 是短连接,即只进行一次数据通信过程,通信完成就关闭连接,这样就不会出现粘包问题。
(2)如果传输的是字符串、文件等无结构化数据时,也不会出现粘包问题。因为发送方只管发送,接收方只管接收存储就行了。
3.3 粘包问题的解决办法
粘包问题的最本质原因在于接收方无法分辨出消息与消息之间的边界在哪?我们通过使用某种方法给出边界,常用的方法有以下几种:
(1)发送定长包。即发送端将每个数据包封装为固定长度(长度不够的可以通过补0填充),这样接收端每次从接收缓冲区中读取固定长度的数据就自然而然的把每个数据包拆分开来。(适合定长结构的数据)
(2)包头加上包体长度。发送端给每个数据包添加包首部,首部中应该至少包含数据包的长度,这样接收端在接收到数据后,通过读取包首部的长度字段,便可以知道每一个数据包的实际长度了。(适合不定长结构的数据)
(3)在包尾部设置边界标记。发送端在每个数据包尾部添加边界标记,可以使用特殊符号作为边界标记。如此,接收端通过这个边界标记就可以将不同的数据包拆分开来。但这可能会存在一个问题:如果数据包内容中也包含有边界标记,则会被误判为消息的边界,导致出错。这样方法要视具体情况而定。例如,FTP协议就是采用 "\r\n" 来识别一个消息的边界的。
四、粘包解决方案—代码实现
4.1 粘包解决方案1:使用定长包
这里需要实现对 read()、write() 系统调用的封装,函数声明如下:
ssize_t readn(int fd, void *buf, size_t count);
ssize_t writen(int fd, void *buf, size_t count);
这两个封装函数的参数列表和返回值与 read、write 一致。它们的作用是读取 / 写入 count 个字节后再返回。代码实现如下:
/*
readn 函数
读取count字节的数据
*/
ssize_t readn(int fd, void *buf, size_t count)
{
int left = count; //剩余的字节数
char * ptr = (char*)buf ;
while(left>0)
{
int readBytes = read(fd, ptr, left);
if(readBytes < 0)//read函数小于0有两种情况:1中断;2出错
{
if(errno == EINTR) //读被中断
{
continue;
}
else
return -1;
}
if(readBytes == 0) //读到了EOF
{
//对方关闭了呀
printf("peer close\n");
break;
}
left -= readBytes;
ptr += readBytes;
}
return count - left;
}
/*
writen 函数
写入count字节的数据
*/
ssize_t writen(int fd, void *buf, size_t count)
{
int left = count ;
char * ptr = (char *)buf;
while(left > 0)
{
int writeBytes = write(fd, ptr, left);
if(writeBytes<0)
{
if(errno == EINTR)
continue;
return -1;
}
else if(writeBytes == 0)
continue;
left -= writeBytes;
ptr += writeBytes;
}
return count;
}
有了这两个封装函数,我们就可以使用定长包来发送/接收数据了。示例代码如下:
char readbuf[512] = {0};
readn(conn,readbuf,sizeof(readbuf)); //每次读取512个字节
//同理,写入的时候也写入512个字节
char writebuf[512] = {0};
fgets(writebuf, sizeof(writebuf), stdin); //从终端输入数据
writen(conn, writebuf, sizeof(writebuf));
程序分析:每个消息体都是以固定的 512 字节长度来发送,以此来区分每一个消息,这就是用定长包的方法解决粘包的问题。
缺点:定长包解决方案的缺点在于会增加网络的负担,无论每次发送的有效数据是多大,都得按照固定的数据长度进行发送。
4.2 粘包解决方案2:使用结构体,显式说明数据部分的长度
在这个方案中,我们需要定义一个消息结构体,结构体中指明包体中数据部分的长度,用4个字节的无符号整数来表示。当接收端收到消息后,先读取前4个字节,获取消息的长度,然后根据消息长度进行数据的读取。定义的结构体如下:
typedef unsigned int uint32;
typedef struct packet
{
uint32 msgLen; //4个字节大小,原来描述数据部分的长度
char data[512]; //数据部分
}PACKET_T;
读写过程如下所示,这里只给出关键代码进行说明:
//发送数据过程
PACKET_T writebuf;
memset(&writebuf,0,sizeof(writebuf));
while(fgets(writebuf.data, sizeof(writebuf.data), stdin) != NULL)
{
int n = strlen(writebuf.data); //计算要发送的数据的字节数
writebuf.msgLen =htonl(n); //将数据部分长度保存在msgLen字段中,注意字节序的转换
writen(conn, &writebuf, 4+n); //发送数据,数据长度为4个字节的msgLen 加上data长度
memset(&writebuf,0,sizeof(writebuf));
}
下面是读取数据的过程,先读取4个字节长度,获取msgLen字段的值,该字段指示了有效数据的长度,然后依据该字段给出长度再读取data部分。
#define err_exit(m)\
do{\
perror(m);\
exit(EXIT_FAILURE);\
}while(0)
//读取数据过程
PACKET_T readbuf;
memset(&readbuf,0,sizeof(readbuf));
int ret = readn(conn, &readbuf.msgLen, 4); //先读取四个字节,确定后续数据部分的长度
if(ret == -1)
{
err_exit("readn");
}
else if(ret == 0)
{
printf("peer close\n");
break;
}
int dataBytes = ntohl(readbuf.msgLen); //字节序的转换
int readBytes = readn(conn, readbuf.data, dataBytes); //读取出后续的数据
if(readBytes == 0)
{
printf("peer close\n");
break;
}
if(readBytes<0)
{
err_exit("read");
}
4.3 粘包解决方案3:按行读取
FTP 协议就是采用 "\r\n" 标记来识别一个消息的边界的。我们这里实现一个按行读取的函数,该函数能够按 '\n' 来识别消息的边界。这里先介绍一个函数:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
与 read 函数相比,recv 函数的区别在于两点:
1. recv 函数只能用于用于socket IO。
2. recv 函数含义 flags 参数,可以指定一些选项。
recv 函数的 flags 参数常用的选项有:
- MSG_OOB:接收带外数据,即通过紧急指针发送的数据。
- MSG_PEEK:从缓冲区中读取数据,但并不从缓冲区中清除所读数据。
为了实现按行读取,我们需要使用 recv 函数的 MSG_PEEK 选项。PEEK 本意是“偷看、窥视”,我们可以理解为“窥视数据”,看看 socket 的缓冲区是否有某种数据,但不清除缓冲区中的内容。
/**
封装了recv函数
函数功能:从缓冲区中读取指定长度的数据,但不清除缓冲区内容。
返回值说明:-1 读取出错
*/
ssize_t read_peek(int sockfd, void *buf, size_t len )
{
while(1)
{
int ret = recv (sockfd , buf ,len ,MSG_PEEK);
if(ret == -1)
{
if(errno ==EINTR) //出现中断
continue ;
}
return ret ;
}
}
下面的函数实现了按行读取的功能,代码如下:
/**
按行读取数据
参数说明:
sockfd: 套接字描述符
buf: 应用层缓冲区,保存读取到的数据
maxline: 所规定的一行的长度
返回值说明:
== 0 : 对端关闭
== -1 :读取错误
其他: 一行的字节数,包含\n
*/
ssize_t read_line (int sockfd , void *buf ,size_t maxline)
{
int ret;
int nRead = 0;
int left = maxline ; //剩下的字节数
char *pbuf = (char *) buf ;
int count = 0;
while(1)
{
ret = read_peek ( sockfd, pbuf, left); //从socket缓冲区中读取指定长度的内容,但并不删除
if(ret <0)
{
return ret;
}
nRead = ret;
for(int i = 0; i<nRead; ++i)//看看读取出来的数据中是否有换行符\n
{
if(pbuf[i]=='\n')//如果有换行符
{
ret = readn(sockfd , pbuf , i+1);//读取一行
if(ret != i+1) //一定会读到i+1个字符,否则是读取出错
{
exit(EXIT_FAILURE);
}
return ret + count ;
}
}
//如果窥探的数据中并没有换行符
//把这段没有换行符\n的内容读取出来
ret = readn(sockfd , pbuf, nRead);
if(ret != nRead )
{
exit(EXIT_FAILURE);
}
pbuf += nRead;
left -= nRead;
count += nRead;
}
return -1;
}
参考
TCP粘包,拆包及解决方法
Socket编程(4)TCP粘包问题及解决方案
TCP新手误区--粘包的处理文章来源:https://www.toymoban.com/news/detail-404285.html
TCP粘包问题分析和解决(全)文章来源地址https://www.toymoban.com/news/detail-404285.html
到了这里,关于TCP协议-TCP粘包问题的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!