Socket粘包问题「建议收藏」

2022-09-22 14:48:47 浏览数 (1)

大家好,又见面了,我是你们的朋友全栈君。

什么时候要考虑粘包问题

1.:如果利用tcp每次发送数据,就与对方建立连接,然后双方发送完一段数据后,就关闭连接,这样就不会出现粘包问题(因为只有一种包结构,类似于http协议)。关闭连接主要要双方都发送close连接(参考tcp关闭协议)。如:A需要发送一段字符串给B,那么A与B建立连接,然后发送双方都默认好的协议字符如”hello give me sth abour yourself”,然后B收到报文后,就将缓冲区数据接收,然后关闭连接,这样粘包问题不用考虑到,因为大家都知道是发送一段字符。 2.如果发送数据无结构,如文件传输,这样发送方只管发送,接收方只管接收存储就ok,也不用考虑粘包。 3.如果双方建立连接,需要在连接后一段时间内发送不同结构数据,如连接后,有好几种结构:

代码语言:javascript复制
"hello give me sth abour yourself" "Don't give me sth abour yourself" 

那这样的话,如果发送方连续发送这个两个包出去,接收方一次接收可能会是”hello give me sth abour yourselfDon’t give me sth abour yourself” 这样接收方就傻了,到底是要干嘛?不知道,因为协议没有规定这么诡异的字符串,所以要处理把它分包,怎么分也需要双方组织一个比较好的包结构,所以一般可能会在头加一个数据长度之类的包,以确保接收。

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

发送端需要等缓冲区满才发送出去,造成粘包 接收方不及时接收缓冲区的包,造成多个包接收

解决办法: 为了避免粘包现象,可采取以下几种措施。一是对于发送方引起的粘包现象,用户可通过编程设置来避免,TCP提供了强制数据立即传送的操作指令push,TCP软件收到该操作指令后,就立即将本段数据发送出去,而不必等待发送缓冲区满;二是对于接收方引起的粘包,则可通过优化程序设计、精简接收进程工作量、提高接收进程优先级等措施,使其及时接收数据,从而尽量避免出现粘包现象;三是由接收方控制,将一包数据按结构字段,人为控制分多次接收,然后合并,通过这种手段来避免粘包。

以上提到的三种措施,都有其不足之处。第一种编程设置方法虽然可以避免发送方引起的粘包,但它关闭了优化算法,降低了网络发送效率,影响应用程序的性能,一般不建议使用。第二种方法只能减少出现粘包的可能性,但并不能完全避免粘包,当发送频率较高时,或由于网络突发可能使某个时间段数据包到达接收方较快,接收方还是有可能来不及接收,从而导致粘包。第三种方法虽然避免了粘包,但应用程序的效率较低,对实时应用的场合不适合。

网络通讯的封包和拆包

对于基于TCP开发的通讯程序,有个很重要的问题需要解决,就是封包和拆包.

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

TCP是个”流”协议,所谓流,就是没有界限的一串数据,如河里的流水,是连成一片的,其间是没有边界限的。但是一般通讯程序开发是需要定义一个个相互独立的数据包,比如用于登录的数据包,用于注销的数据包,由于TCP”流”的特性以及网络状况,在进行数据传输时会出现一下情况。 假设我们连续两次send分别发送两段数据data1和data2,在接收端有以下几种接收情况(当然不止这几种情况,这里值列出有代表性的情况)

先接收到data1,然后接收到data2
先接收到data1的部分数据,然后接收到data1余下的部分以及data2的全部
先接收到datat1的全部数据和data2的部分数据,然后接收到了data2的余下数据
一次性接收到了data1和data2的全部数据

对于A情况来说,正是我们需要的,对于BCD的情况就是大家经常说的粘包,就需要我么把接收到的数据进行拆包,拆成一个独立的数据包,为了拆包就必须在发送端进行封包。 另:对于UDP来说就不存在拆包的问题,因为UDP是个“数据报”协议,也就是两段数据间是有界限的,在接收端要么接收不到数据要么就是接收一个完整的一段数据,不会少接收也不会多接收。

二.其他情况分析

由Nagle算法造成的发送端的粘包.Nagle算法是一种改善网络传输效率的算法.简单来说,当我们提交一段数据给TCP发送后,TCP不会立即发送此段数据,而是等待一小段时间,看看在等待期间是否还有要发送的数据,若有则会一次把这两段数据发送出去。这是对Nagle算法一个简单的解释,刚才最后两种情况就有可能是Nagle算法造成的。
接收端接收不及时造成的接收端粘包:TCP会把接收到的数据存在自己的缓冲区,然后通知应用层取数据,当应用层由于某些原因不能及时的把TCP的数据取出来,就会造成TCP缓冲区存放了几段数据。

三.哪些不需要处理粘包

连续的数据流不需要处理。如一个在线视频,它是一个连续不断的流, 不需要考虑分包。 每发一个消息,建一次连接的情况。 发送端使用了TCP强制数据立即传送的操作指令push。 UDP, 前面已说明白了。在这在强调一下,UDP不需要处理,免的忘记了

四.怎么封包和拆包

最初遇到粘包的问题,尝试过通过在两次send之间调用sleep来休眠一小段时间来解决。这个解决方法的缺点是显而易见的,使传输效率大大降低。后来通过采用应答的方式来解决,尽管在大多数的时候是可行的,但是不能解决第二种情况(自动切片),而且采用应答方式增加了通讯量,加重了网络负荷,再后来就是对数据包进行封包和拆包的操作。

封包

封包就是给一段数据加上包头,这样一来数据包就分为包头和包体两部分内容了(过滤非法包时封包会加入包尾内容)。包头其实上是个大小固定的结构体,其中有个结构体成员变量表示包体的长度,这是个很重要的变量,其他的结构体成员可根据自己的需求定义。根据包头长度固定以及包头中含有包体长度的变量就能正确的拆分出一个完整的数据包。 拆包的两种常用方式。

1.动态缓冲区暂存方式,之所以说缓冲区是动态的是因为当需要缓冲的数据长度超出缓冲区的长度会增大缓冲区长度。

大概过程描述如下: A.为每一个连接动态分配一个缓冲区,同时把此缓冲区和SOCKET关联,常用的是通过结构体关联。 B.当接收到数据时首先把此段数据存放在缓冲区中。 C.判断缓冲区的数据长度是否够一个包头的长度,如不够,择不进行拆包操作 D.根据包头数据解析出里面代表包体长度的变量 E.判断缓冲区中除包头外的数据长度是否够一个包体的长度。如不够,则不进行拆包操作。 F.取出整个数据包,这里的”取”的意思不光从缓冲区中拷贝出数据包,而且要把此数据包从缓冲区冲删除掉,删除的办法就是把此包后面的数据移动到缓冲区的起始地址。 这种方法有两个缺点:1.为每个连接动态分配一个缓冲区增大了内存的使用。2.有三个地方需要拷贝数据,一个地方是把数据存放在缓冲区,一个地方是把完整的数据包从缓冲区取出来,一个地方是把数据包从缓冲区删除。第二种拆包的方法会解决和完善这些问题。前面提到的问题下面有一个改进办法,即采用环形缓冲。但是这种改进办法不能解决第一个缺点以及第一个数据拷贝,只能解决第三个地方的数据拷贝(这个地方是拷贝数据最多的地方)。第二种拆包方式会解决这些问题。环形缓冲实现方案是定义两个指针,分别指向有效数据的头和尾,在存放数据和删除数据的时候直至进行头尾指针移动。

五、粘包问题的解决方案

本质上是要在应用层维护消息与消息的边界 1、定长包 2、包尾加rn(ftp) 3、包头加上包体长度 4、更复杂的应用层协议

对于条目2,缺点是如果消息本身含有rn字符,则也分不清消息的边界。 对于条目1,即我们需要发送和接收定长包。因为TCP协议是面向流的,read和write调用的返回值往往小于参数指定的字节数。对于read调用(套接字标志为阻塞),如果接收缓冲区中有20字节,请求读100个字节,就会返回20。对于write调用,如果请求写100个字节,而发送缓冲区中只有20个字节的空闲位置,那么write会阻塞,直到把100个字节全部交给发送缓冲区才返回。为避免这些情况干扰主程序的逻辑,确保读写我们所请求的字节数,我们实现了两个包装函数readn和writen,如下所示。

代码语言:javascript复制
ssize_t readn(int fd, void *buf, size_t count) { size_t nleft = count; ssize_t nread; char *bufp = (char *)buf; while (nleft > 0) { if ((nread = read(fd, bufp, nleft)) < 0) { if (errno == EINTR) continue; return -1; } else if (nread == 0) //对方关闭或者已经读到eof return count - nleft; bufp  = nread; nleft -= nread; } return count; } ssize_t writen(int fd, const void *buf, size_t count) { size_t nleft = count; ssize_t nwritten; char *bufp = (char *)buf; while (nleft > 0) { if ((nwritten = write(fd, bufp, nleft)) < 0) { if (errno == EINTR) continue; return -1; } else if (nwritten == 0) continue; bufp  = nwritten; nleft -= nwritten; } return count; } 

需要注意的是一旦在我们的客户端/服务器程序中使用了这两个函数,则每次读取和写入的大小应该是一致的,比如设置为1024个字节,但定长包的问题在于不能根据实际情况读取数据,可能会造成网络阻塞,比如现在我们只是敲入了几个字符,却还是得发送1024个字节,造成极大的空间浪费。 此时条目3是比较好的解决办法,其实也可以算是自定义的一种简单应用层协议。比如我们可以自定义一个包体结构

代码语言:javascript复制
struct packet { int len; char buf[1024]; }; 

先接收固定的4个字节,从中得知实际数据的长度n,再调用readn 读取n个字符,这样数据包之间有了界定,且不用发送定长包浪费网络资源,是比较好的解决方案。服务器端在前面的fork程序的基础上把do_service函数更改如下:

代码语言:javascript复制
void do_service(int conn) { struct packet recvbuf; int n; while (1) { memset(&recvbuf, 0, sizeof(recvbuf)); int ret = readn(conn, &recvbuf.len, 4); if (ret == -1) ERR_EXIT("read error"); else if (ret < 4) //客户端关闭 { printf("client closen"); break; } n = ntohl(recvbuf.len); ret = readn(conn, recvbuf.buf, n); if (ret == -1) ERR_EXIT("read error"); if (ret < n) //客户端关闭 { printf("client closen"); break; } fputs(recvbuf.buf, stdout); writen(conn, &recvbuf, 4   n); } } 

对于条目4,举例如 如TLV 编解码格式 struct TLV { uint8_t tag; uint16_t len; char value[0]; }attribute((packed)); 注意value分配的是0大小,最后一个成员为可变长的数组(c99中的柔性数组),对于TLV(Type-Length-Value)形式的结构,或者其他需要变长度的结构体,用这种方式定义最好。使用起来非常方便,创建时,malloc一段结构体大小加上可变长数据长度的空间给它,可变长部分可按数组的方式访问,释放时,直接把整个结构体free掉就可以了。attribute(packed)用来强制不对struct TLV进行4字节对齐,目的是为了获取真实的TLV的空间使用情况。

代码语言:javascript复制
int main(void) { char *szMsg = "aaaaaaaaa"; cout << sizeof(TLV) << endl; //the size of TLV uint16_t len = strlen(szMsg)   1; struct TLV *pTLV; pTLV = (struct TLV *)malloc(sizeof(struct TLV)   sizeof(char) * len); pTLV->tag = 0x2; pTLV->len = len; memcpy(pTLV->value, szMsg, len); cout << pTLV->value << endl; free(pTLV); pTLV = NULL; return 0; } 

发布者:全栈程序员栈长,转载请注明出处:https://javaforall.cn/169712.html原文链接:https://javaforall.cn

0 人点赞