Qt中对TCP粘包的处理

Posted qq_40170041

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Qt中对TCP粘包的处理相关的知识,希望对你有一定的参考价值。

当时用TCP协议传输数据时,经常出现粘包的现象
当服务器向客户端发送数据之后,客户端还没有接收数据的时候,这段时间数据在什么地方?
1、服务器?服务器已经发出数据了
2、网线?数据应该在内存,怎么会在网线里面,又没有内存
3、客户端?是的,这个时候数据已经到达客户端了,只不过被保存在客户端的缓存中了(内核缓冲区),客户端只有在read的时候才能读出数据
场景:服务器每次给客户端发出一条数据,但是每次发送数据的量是不一样的,这时要求客户端把服务器发过来的数据依次接收到本地并且进行对应的解析,如果客户端一次发出10个字节,那么客户端也一次读出10个字节,如果多读了,那么就把下一条数据读出来了,此时解析数据会是错误的,这就是TCP粘包

处理办法:发送端在每一个数据包前面加上包头,包头中加入数据长度
接收端接收到后的处理:首先包头的大小是固定的,一般就是一个long或者int类型,所以我们根据这个long或者int类型求出一个固定大小,8字节(long)或者4字节(int),所以在读数据包的时候直接根据这个类型先去读8字节或者4字节,这样就可以读出数据包的长度,然后根据这个长度去读后边的这个数据块
比如此次接收到的长度为100,那么就向后读取100个字节的数据,就是此次的一个包,哪怕此时缓冲区有1000个字节数据,只读这100个字节就能获取一个完整的包,剩余的900个字节就需要下一次去处理,下次处理的时候还是先读包头,读出数据包的一个长度,然后根据这个长度去读取相应的数据,这样一次一次读取就可以一点一点把数据拆分出来了

例:这里以Qt编写的基于opencv的人脸识别的服务器和客户端为例,客户端发送拍下的人脸发送到服务器进行识别,要求传输一帧完整的人脸数据,这就有可能粘包,可能同时发送两个人脸向服务器,此时就需要处理粘包
首先客户端发送图片数据

//把Mat数据转化为QbyteArray, --》编码成jpg格式
std::vector<uchar> buf;
cv::imencode(".jpg",srcImage,buf);   //这就是将拍摄的原始的图像转为jpg然后将数据放到buf中
QByteArray byte((const char*)buf.data(),buf.size()); //数据格式转为QByteArray 
//准备发送
quint64 backsize = byte.size();    //获取数据的长度,这里可以看到backsize是quint64型变量,占8个字节
QByteArray sendData;
QDataStream stream(&sendData,QIODevice::WriteOnly);
stream.setVersion(QDataStream::Qt_5_14);
//将数据放入码流,首先放入数据的长度backsize,quint64为8字节的长度,后面就是数据
stream<<backsize<<byte;  
//发送
msocket.write(sendData);  //将数据包发送

服务器接收图片数据

static quint64 bsize = 0;  //全局变量

QDataStream stream(msocket); //把套接字绑定到数据流
stream.setVersion(QDataStream::Qt_5_14);

if(bsize == 0)
		//查看目前TCP的内存缓冲区的数据长度是否能达到bsize所占的字节数,这里应该是8字节
    if( msocket->bytesAvailable() < (qint64)sizeof(bsize) ) 
    		return ;
    //说明数据长度够8个字节,然后就可以获取采集数据的长度
    stream>>bsize;


//获取目前缓存中剩余数据的长度,小于刚才获取的8字节的数据长度说明数据还没有发送完成,返回继续等待
if(msocket->bytesAvailable() < bsize)

    return ;  //此时bsize没有清空,下次还会来这里检查获取的数据长度是否大于或等于bsize

QByteArray data;
stream>>data;
bsize = 0;   // 将bsize设为0,说明处理完了一包数据 
if(data.size() == 0)//没有读取到数据

    return;


//显示图片
QPixmap mmp;
mmp.loadFromData(data,"jpg");
mmp = mmp.scaled(ui->picLb->size());
ui->picLb->setPixmap(mmp);

TCP 粘包与半包的核心

技术图片

  进行 Socket 编程时经常会碰到 TCP 的粘包与半包问题,很多时候我们选用 netty 等框架而不直接采用原生的 Socket 编程也是因为 netty 帮我们将该类传输过程中可能出现的问题屏蔽掉了,使我们可以抽出更多精力来关注功能的实现,而不是挣扎在处理这些底层问题上。但尽管如此,我们也必须要对这些问题有所了解。

认识问题

  想要了解粘包与半包问题,首先要了解 TCP 报文的发送过程,以传统的 BIO 为例子:

  1. 我们调用操作系统提供的系统函数,建立一个 Socket 监听,监听线程会阻塞在 socket 的 accept 方法上,直到有连接请求到来。

技术图片

 

   2. 有客户端发起连接请求,服务端与客户端进行 三次握手 。三次握手 是操作系统层面的协议栈完成的,我们在应用层编程感知不到,直到三次握手完成,客户端与服务端建立了一个 TCP 连接,我们步骤 1 中阻塞在 accept 方法的线程被唤醒。

  3. 连接建立后,操作系统会在 内核空间 为本次连接分配两个缓冲区:发送缓冲区 和 接收缓冲区(体现了 TCP 协议是全双工协议), 我们可以通过 socket 实例拿到这两个缓冲区。之后数据从发送缓冲区封装为 TCP 报文传输到网卡、以及接收到的报文被层层拆包后内容传输到接收缓冲区是操作系统的任务。我们要做的,是建立一个线程扫描接收缓冲区,一旦有数据写入则将数据读入进程空间;同时如果有数据需要发送则将数据写入发送缓冲区。

技术图片

 

  服务端与客户端就这样,接收缓冲区一旦接收到数据便读取进进程空间,有数据需要发送就写入到发送缓冲区,循环往复直到本次连接完成四次挥手。

  我们可以发现,有数据就读,我们并无法得知这些数据的边界。比如,客户端发送了两个报文 AB 和 CD ,因为报文的大小很小,如果两次发送的间隔时间很短的话,很可能 AB 还在发送缓冲区,没有来得及被封装为报文, CD 便也被写入进发送缓冲区了。这样在发送时原本应该是两个报文的数据便会被封装到一个报文中发送给服务端。服务端并无法区别这是两个报文还是一个报文,只知道把数据整个的读入进程空间中,这就是 TCP 的粘包。

  再考虑一种情况,我们一次请求中携带的数据非常多,操作系统的协议栈将我们这一次请求分割为了多个报文发送到服务端。多个报文到达后,服务端并无法区别哪些包合并起来是一次完整的请求,这便是 TCP 的半包。

  看起来问题的根源在于,将数据从发送缓冲区打包发出和将数据从网卡拆包写入接收缓冲区这两个动作是操作系统完成的,操作系统可能调用了标准I/O库,也可能通过更高层的封装完成这些事情,但不管怎样我们无法控制打包和拆包的时机。

  再深入想一下,操作系统中协议栈的实现并没有将打包和拆包时机的控制权交给我们,协议栈是对底层协议的实现,TCP 协议便是这样定义的通讯过程。

  也就是说,TCP 协议只负责建立可靠的传输通道,保证数据的准确有序的到达,但 TCP 协议不会帮我们定义数据的边界。

  那么问题的根源找到了: 

  TCP 是流式协议,消息无边界。

  (PS : UDP 虽然也可以一次传输多个包或者多次传输一个包,但每个消息都是有边界的,因为 UDP 是无连接的,因此不会有粘包和半包问题。)

解决问题

  找到了问题的原因,我们再来考虑解决方案。

  既然问题是传输层不帮我们确定消息的边界,那么我们在应用层自己为消息设置边界就好了。

  目前主流的解决方案有四种:

  1. 将数据封装为帧。也就是数据固定长度,不管你发送了什么,服务端读到固定长度的数据就判定这是一次完整的请求。

  2. 通过标识位为数据添加边界。比如换行符,服务端每读到一个换行符便认定,之前读到的数据是一次完整的请求。

  3. 通过固定字段标识本次请求的长度。比如我们规定每次发送数据,头两个字节标识本次请求的数据长度。服务端收到请求后先读取两个字节,转换为 int ,后读取该长度的数据。长度用完则标识一次完整的请求读完了。

  4. 使用短连接,一次请求只结束便关闭该链接。这样类似 UDP ,为消息添加了天然的边界,但缺点也很明显,频繁的三次握手和四次挥手及其浪费系统资源。

  方式 1 不灵活,不能充分利用系统资源,但好在实现简单;方式 2 需要对数据进行转义防止请求内容中包含我们约定的标识,但也好在实现简单;方式 3 比较通用,HTTP 协议 header 中的 Content-length 字段便是用来标识本次请求的长度,但实现较前两种而言更加复杂。

  使用哪种方式要结合具体的场景决定,通常情况下推荐使用方式 3 。当然既然是为消息添加边界,方式自然多种多样,比如如果传输的是 json ,可以以 { } 对为边界来判断数据是否完整,类似该类特殊场景下的处理方式不再一一列举。

以上是关于Qt中对TCP粘包的处理的主要内容,如果未能解决你的问题,请参考以下文章

使用 Qt 获取 UDP 数据并显示成图片

Qt Socket 收发图片——图像拆包组包粘包处理

在 qt 中使用 TCP IP 发送结构

day08 多线程socket 编程,tcp粘包处理

Qt学习第四天

day8---多线程socket 编程,tcp粘包处理