IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 网络协议 -> TCP协议-TCP粘包问题 -> 正文阅读

[网络协议]TCP协议-TCP粘包问题

一、前言

??????? 我们知道,TCP是一个面向字节流的传输层协议。“流” 意味着 TCP 所传输的数据是没有边界的。这不同于 UDP 协议提供的是面向消息的传输服务,其传输的数据是有边界的。TCP 的发送方无法保证对方每次收到的都是一个完整的数据包。于是就有了粘包、拆包问题的出现。粘包、拆包问题只发生在TCP协议中。

二、什么是粘包、拆包?

假设客户端向服务器连续发送了两个数据包,用 packet1 和 packet2 来表示,那么服务端收到的数据可以分为下面三种情况:

  • 第一种情况,接收端正常收到两个数据包,即没有发生拆包和粘包的现象,此种情况不在本文的讨论范围内。

  • 第二种情况,接收端只收到一个TCP报文段,但是这一个报文段的数据部分包含了发送端发送来的两个数据包的信息,这种现象即为粘包问题。这种情况由于接收端不知道这两个数据包的界限,所以对于接收端来说很难处理。

  • 第三种情况,这种情况有两种表现形式,如下图。接收端收到了两个TCP报文段,但是去掉首部后,两个数据包要么是不完整的,要么就是多出来一部分,这种现象即为粘包、拆包问题。同第二种情况一样,由于不知道两个数据包的界限,对于接收端来说同样不好处理。

三、粘包产生原因及解决办法

3.1 TCP粘包产生原因

TCP 发生粘包、拆包问题,主要是由于以下几个原因:

(1)应用进程写入的数据量大于TCP发送缓冲区的大小,这将会发生拆包。应用进程调用write()系统调用,将应用层的缓冲区中的数据拷贝到TCP socket 的发送缓冲区。而TCP的发送缓冲区有一个 SO_SNDBUF 的大小限制,如果应用层的缓冲区数据大小大于TCP发送缓冲区的大小,则数据包需要进行拆包处理,分多次进行发送。

(2)应用进程写入的数据量小于TCP发送缓冲区的大小,这将会发生粘包。应用进程调用write()系统调用,将应用层的缓冲区中的数据拷贝到TCP socket 的发送缓冲区。由于TCP发送缓存空间足够,它会等到有多个数据包时,再组装成一个TCP报文段,然后通过网卡发送到网络中去。

(3)当应用进程发送的数据包 packet 大于 MSS(最大报文段长度)时,将会发生拆包。TCP在传送数据时,是以报文段为单位发送数据的,而待发送的数据是以 MSS 为单位进行分割的。如果应用进程发送的数据包packet的长度大于 MSS,必然需要对该数据包进行拆包处理。

??????? 以上这几种情况,都会导致一个完整的应用层数据包被分割成多片发送出去,从而使接收方不是按完整的数据包方式来接收数据。

<注1> MSS(Maximum Segment Size,最大报文段长度)? 这个名词的含义是:每一个 TCP 报文段中的数据部分的最大长度。它不是指整个TCP报文段的最大长度,而是 “TCP报文段的长度 - TCP首部长度”。TCP 在组装报文段时,是以 MSS 为单位对需要传送的数据进行分割的,然后加上TCP首部后,就组装成一个完整的TCP报文段了。MSS的默认值一般是 536 字节长。

<注2> TCP报文段 = TCP首部 + TCP数据部分

3.2 粘包问题的解决办法

粘包问题的最本质原因在于接收方无法分辨出消息与消息之间的边界在哪?我们通过使用某种方法给出边界,常用的方法有以下几种:

(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粘包问题及解决方案

  网络协议 最新文章
使用Easyswoole 搭建简单的Websoket服务
常见的数据通信方式有哪些?
Openssl 1024bit RSA算法---公私钥获取和处
HTTPS协议的密钥交换流程
《小白WEB安全入门》03. 漏洞篇
HttpRunner4.x 安装与使用
2021-07-04
手写RPC学习笔记
K8S高可用版本部署
mySQL计算IP地址范围
上一篇文章      下一篇文章      查看所有文章
加:2021-08-28 09:42:41  更:2021-08-28 09:45:12 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/25 21:46:13-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码