前言
这篇记录TCP通信粘包问题的处理方法。
一、粘包问题是什么?
粘包问题是服务器收发数据常遇到的一个现象,下面我们介绍一下粘包问题是什么,当客户端发送多个数据包给服务器时,服务器底层的tcp接收缓冲区收到的数据为粘连在一起的,如下图所示
当客户端发送两个Hello World!给服务器,服务器TCP接收缓冲区接收了两次,一次是Hello World!Hello, 第二次是World!。
二、粘包原因
因为TCP底层通信是面向字节流的,TCP只保证发送数据的准确性和顺序性,字节流以字节为单位,客户端每次发送N个字节给服务端,N取决于当前客户端的发送缓冲区是否有数据,比如发送缓冲区总大小为10个字节,当前有5个字节数据(上次要发送的数据比如’loveu’)未发送完,那么此时只有5个字节空闲空间,我们调用发送接口发送hello world!其实就是只能发送Hello给服务器,那么服务器一次性读取到的数据就很可能是loveuhello。而剩余的world!只能留给下一次发送,下一次服务器接收到的就是world!
如下图
这是最好理解的粘包问题的产生原因。还有一些其他的原因比如
1 客户端的发送频率远高于服务器的接收频率,就会导致数据在服务器的tcp接收缓冲区滞留形成粘连,比如客户端1s内连续发送了两个hello world!,服务器过了2s才接收数据,那一次性读出两个hello world!。
2 tcp底层的安全和效率机制不允许字节数特别少的小包发送频率过高,tcp会在底层累计数据长度到一定大小才一起发送,比如连续发送1字节的数据要累计到多个字节才发送,可以了解下tcp底层的Nagle算法。
3 再就是我们提到的最简单的情况,发送端缓冲区有上次未发送完的数据或者接收端的缓冲区里有未取出的数据导致数据粘连。
一、处理粘包
把发送的数据格式变为消息长度+消息内容的方式。下面是消息节点的数据结构。
class MsgNode
{
friend class CSession;
public:
MsgNode(char * msg, short max_len):_total_len(max_len + HEAD_LENGTH),_cur_len(0){
_data = new char[_total_len+1]();
memcpy(_data, &max_len, HEAD_LENGTH);
memcpy(_data+ HEAD_LENGTH, msg, max_len);
_data[_total_len] = '\0';
}
MsgNode(short max_len):_total_len(max_len),_cur_len(0) {
_data = new char[_total_len +1]();
}
~MsgNode() {
delete[] _data;
}
void Clear() {
::memset(_data, 0, _total_len);
_cur_len = 0;
}
private:
short _cur_len;
short _total_len;
char* _data;
};
1 两个参数的构造函数做了完善,之前的构造函数通过消息首地址和长度构造节点数据,现在需要在构造节点的同时把长度信息也写入节点,该构造函数主要用来发送数据时构造发送信息的节点。
2 一个参数的构造函数为较上次新增的,主要根据消息的长度构造消息节点,该构造函数主要是接收对端数据时构造接收节点调用的。
3 新增一个Clear函数清除消息节点的数据,主要是避免多次构造节点造成开销。
在服务器进行读操作时,可以把读数据头部(消息长度)和数据内容(消息内容)分开读,在开始的时候先读数据的头部,再根据头部读到的数据长度读取数据,在数据读取结束后再次读取头部就这样分开读。
1.获取数据头部
我们可以读取指定的头部长度,大小为HEAD_LENGTH字节数,只有读完HEAD_LENGTH字节才触发HandleReadHead函数。
void CSession::Start(){
_recv_head_node->Clear();
boost::asio::async_read(_socket, boost::asio::buffer(_recv_head_node->_data, HEAD_LENGTH), std::bind(&CSession::HandleReadHead, this,
std::placeholders::_1, std::placeholders::_2, SharedSelf()));
}
这样我们可以直接在HandleReadHead函数内处理头部信息
void CSession::HandleReadHead(const boost::system::error_code& error, size_t bytes_transferred, std::shared_ptr<CSession> shared_self) {
if (!error) {
if (bytes_transferred < HEAD_LENGTH) {
cout << "read head lenth error";
Close();
_server->ClearSession(_uuid);
return;
}
//头部接收完,解析头部
short data_len = 0;
memcpy(&data_len, _recv_head_node->_data, HEAD_LENGTH);
cout << "data_len is " << data_len << endl;
//此处省略字节序转换
// ...
//头部长度非法
if (data_len > MAX_LENGTH) {
std::cout << "invalid data length is " << data_len << endl;
_server->ClearSession(_uuid);
return;
}
_recv_msg_node= make_shared<MsgNode>(data_len);
boost::asio::async_read(_socket, boost::asio::buffer(_recv_msg_node->_data, _recv_msg_node->_total_len),
std::bind(&CSession::HandleReadMsg, this,
std::placeholders::_1, std::placeholders::_2, SharedSelf()));
}
else {
std::cout << "handle read failed, error is " << error.what() << endl;
Close();
_server->ClearSession(_uuid);
}
}
接下来根据头部内存储的消息体长度,获取指定长度的消息体数据,所以再次调用async_read,指定读取_recv_msg_node->_total_len长度,然后触发HandleReadMsg函数。
2.获取消息体
HandleReadMsg函数内解析消息体,解析完成后打印收到的消息,接下来继续监听读事件,监听读取指定头部大小字节,触发HandleReadHead函数, 然后再在HandleReadHead内继续监听读事件,获取消息体长度数据后触发HandleReadMsg函数,从而达到循环监听的目的。
void CSession::HandleReadMsg(const boost::system::error_code& error, size_t bytes_transferred,
std::shared_ptr<CSession> shared_self) {
if (!error) {
PrintRecvData(_data, bytes_transferred);
std::chrono::milliseconds dura(2000);
std::this_thread::sleep_for(dura);
_recv_msg_node->_data[_recv_msg_node->_total_len] = '\0';
cout << "receive data is " << _recv_msg_node->_data << endl;
Send(_recv_msg_node->_data, _recv_msg_node->_total_len);
//再次接收头部数据
_recv_head_node->Clear();
boost::asio::async_read(_socket, boost::asio::buffer(_recv_head_node->_data, HEAD_LENGTH),
std::bind(&CSession::HandleReadHead, this, std::placeholders::_1, std::placeholders::_2,
SharedSelf()));
}
else {
cout << "handle read msg failed, error is " << error.what() << endl;
Close();
_server->ClearSession(_uuid);
}
}
总结
以上就是处理粘包问题的一种方法,使用async_read一次性读完一整个消息头部和一整个消息体,这样就做到把粘在一起数据读的时候分开了。