Socket编程(4)TCP粘包问题及解决方案

① TCP是个流协议,它存在粘包问题

TCP是一个基于字节流的传输服务,"流"意味着TCP所传输的数据是没有边界的。这不同于UDP提供基于消息的传输服务,其传输的数据是有边界的。TCP的发送方无法保证对等方每次接收到的是一个完整的数据包。主机A向主机B发送两个数据包,主机B的接收情况可能是

产生粘包问题的原因有以下几个:

  • 第一 。应用层调用write方法,将应用层的缓冲区中的数据拷贝到套接字的发送缓冲区。而发送缓冲区有一个SO_SNDBUF的限制,如果应用层的缓冲区数据大小大于套接字发送缓冲区的大小,则数据需要进行多次的发送。
  • 第二种情况是,TCP所传输的报文段有MSS的限制,如果套接字缓冲区的大小大于MSS,也会导致消息的分割发送。
  • 第三种情况由于链路层最大发送单元MTU,在IP层会进行数据的分片。

这些情况都会导致一个完整的应用层数据被分割成多次发送,导致接收对等方不是按完整数据包的方式来接收数据。

② 粘包的问题的解决思路 粘包问题的最本质原因在与接收对等方无法分辨消息与消息之间的边界在哪。我们通过使用某种方案给出边界,例如:

  • 发送定长包。如果每个消息的大小都是一样的,那么在接收对等方只要累计接收数据,直到数据等于一个定长的数值就将它作为一个消息。
  • 包尾加上\r\n标记。FTP协议正是这么做的。但问题在于如果数据正文中也含有\r\n,则会误判为消息的边界。
  • 包头加上包体长度。包头是定长的4个字节,说明了包体的长度。接收对等方先接收包体长度,依据包体长度来接收包体。
  • 使用更加复杂的应用层协议。

③ 粘包解决方案一:使用定长包 这里需要封装两个函数:

ssize_t readn(int fd, void *buf, size_t count)
ssize_t writen(int fd, void *buf, size_t count)

这两个函数的参数列表和返回值与readwrite一致。它们的作用的读取/写入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;
                        }
                        return -1;
                }
                if(readBytes == 0)//读到了EOF
                {
                        //对方关闭呀
                        printf("peer close\n");
                        return count - left;
                }
                left -= readBytes;
                ptr += readBytes ;
        }
        return count ;
}

/*
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];
readn(conn,readbuf,sizeof(readbuf));  //每次读取512个字节

同理的,写入的时候也写入512个字节

char writebuf[512];
 fgets(writebuf,sizeof(writebuf),stdin);
 writen(conn,writebuf,sizeof(writebuf);

每个消息都以固定的512字节(或其他数字,看你的应用层的缓冲区大小)来发送,以此区分每一个信息,这便是以固定长度解决粘包问题的思路。定长包解决方案的缺点在于会导致增加网络的负担,无论每次发送的有效数据是多大,都得按照定长的数据长度进行发送。

④ 粘包解决方案二:使用结构体,显式说明数据部分的长度

在这个方案中,我们需要定义一个‘struct packet’包结构,结构中指明数据部分的长度,用四个字节来表示。发送端的对等方接收报文时,先读取前四个字节,获取数据的长度,由长度来进行数据的读取。定义一个结构体

struct packet
{
        unsigned int msgLen ;  //4个字节字段,说明数据部分的大小
        char data[512] ;  //数据部分 
}

读写过程如下所示,这里抽取关键代码进行说明:

//发送数据过程
    struct packet 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)); 
    }

下面是读取数据的过程,先读取msgLen字段,该字段指示了有效数据data的长度。依据该字段再读出data。

  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");
}

⑤ 粘包解决方案三:按行读取 ftp协议采用/r/n来识别一个消息的边界,我们在这里实现一个按行读取的功能,该功能能够按/n来识别消息的边界。这里介绍一个函数:

 ssize_t recv(int sockfd, void *buf, size_t len, int flags);

与read函数相比,recv函数的区别在于两点:

  1. recv函数只能够用于套接口IO。
  2. recv函数含有flags参数,可以指定一些选项。

recv函数的flags参数常用的选项是:

  1. MSG_OOB 接收带外数据,即通过紧急指针发送的数据
  2. 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 && errno == EINTR)//文件读取中断
                        continue;
                return ret;
        }
}

下面是按行读取的代码:

/*
*读取一行内容
* 返回值说明:
        == 0 :对端关闭
        == -1 : 读取错误
        其他:一行的字节数,包含\n
* 
**/
ssize_t readLine(int sockfd ,void * buf ,size_t maxline)
{
        int ret ;
        int nRead = 0;
        int left = maxline ;
        char * pbuf  = (char *) buf;
        int count  = 0;
        while(true)
        {
                //从socket缓冲区中读取指定长度的内容,但并不删除
                ret = read_peek(sockfd,pbuf,left);
                // ret = recv(sockfd , pbuf , left , MSG_PEEK);
                if(ret<= 0)
                        return ret;
               nRead = ret ;
                for(int i = 0 ;i< nRead ; ++i)
                {
                        if(pbuf[i]=='\n') //探测到有\n
                        {
                                ret = readn (sockfd , pbuf, i+1);
                                if(ret != i+1)
                                        exit(EXIT_FAILURE);
                                return ret + returnCount;
                        }
                }
                //如果嗅探到没有\n
                //那么先将这一段没有\n的读取出来
                ret  = readn(sockfd , pbuf , nRead);
                if(ret != nRead)
                        exit(EXIT_FAILURE);
                pbuf += nRead ;
                left -= nRead ;
                count += nRead;
        }
        return -1;
}

⑥ 实例程序 下面的链接中包含了上面提到的几种方案的代码,各个函数封装在common.h头文件中,TCP粘包解决方案

文章链接:http://www.cnblogs.com/QG-whz/p/5537447.html

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏chenssy

【死磕Java并发】—–Java内存模型之重排序

在执行程序时,为了提供性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件: 在单线程环境下不能改...

2466
来自专栏java一日一条

在什么情况下,Java比C++慢很多?

垃圾回收器。这是一把“双刃剑”。如果你的程序遵循“大部分对象都在年青代中消亡”模型,垃圾回收器是非常有利的(很少的碎片,更好的缓存局部性)。但是,如果程序不遵循...

432
来自专栏决胜机器学习

PHP数据结构(四) ——队列

PHP数据结构(四)——队列以及简单消息存取 (原创内容,转载请注明来源,谢谢) 队列也是一种特殊的线性表,和栈很相似,区别在于队列对于数据增加和删除的限制和...

3836
来自专栏技术碎碎念

OS存储器管理(二)

离散分配 分页(Paging),分段,段页式 一、分页 一个进程的物理地址可以是非连续的; 将物理内存分成固定大小的块,称为块(frame); 将逻辑内存分为同...

3178
来自专栏C/C++基础

C/C++ volatile

volatile是“易变的”、“不稳定”的意思。volatile是C的一个较为少用的关键字,它用来解决变量在“共享”环境下容易出现读取错误的问题。

913
来自专栏大内老A

使命必达: 深入剖析WCF的可靠会话[协议篇](下)

在《上篇》中,我们认识了从序列创建到终止过程中消息交换的大致流程。接下来,我们进一步将关注点聚焦到单个小消息上,看看在整个基于序列的上下文中,不同类型的消息具有...

1918
来自专栏chenssy

【死磕Java并发】-----Java内存模型之重排序

在执行程序时,为了提供性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件: 1. 在单线程环境...

1142
来自专栏猿人谷

堆和栈的区别

堆和栈的区别 一般认为在c中分为这几个存储区 1栈 - 有编译器自动分配释放 2堆 - 一般由程序员分配释放,若程序员不释放,程序结束时可能由OS回收...

1755
来自专栏芋道源码1024

【死磕Java并发】—– Java内存模型之重排序

在执行程序时,为了提供性能,处理器和编译器常常会对指令进行重排序,但是不能随意重排序,不是你想怎么排序就怎么排序,它需要满足以下两个条件: 在单线程环境下不能改...

3497
来自专栏Golang语言社区

go-concurrent-programming.md

最近在看《Programming in Go》, 其中关于并发编程写得很不错, 受益非浅, 其中有一些例子是需要多思考才能想明白的, 所以我打算记录下来, 强化...

3489

扫码关注云+社区