libhv如何处理粘包,拆包问题

libhv TCP 粘包与拆包处理说明

本文说明 libhv 如何处理 TCP 粘包、拆包问题,并结合当前项目中的 HTcpServerRpcMessage 协议格式分析实际处理流程。

1. TCP 粘包与拆包背景

TCP 是面向字节流的协议,本身不保留应用层消息边界。因此应用层发送的多条消息,在接收端可能出现以下情况:

  • 粘包:一次读取到多个完整应用层包。
  • 拆包/半包:一次只读取到一个应用层包的一部分。
  • 混合情况:一次读取到前一个包的尾部、若干完整包、下一个包的头部。

因此,业务层不能直接把一次 socket read 当作一条完整消息处理,必须根据应用层协议重新切分字节流。

2. libhv 的处理方式

libhv 通过 setUnpack,也就是 C 接口中的 hio_set_unpack,为连接设置拆包规则。设置后,libhv 会在底层读事件中先完成应用层包切分,只有当得到完整包时,才触发业务层 onMessage 回调。

libhv 支持三种常见拆包方式:

  • UNPACK_BY_FIXED_LENGTH:按固定包长拆包。
  • UNPACK_BY_DELIMITER:按分隔符拆包,适合文本协议。
  • UNPACK_BY_LENGTH_FIELD:按头部长度字段拆包,适合二进制协议。

当前项目使用的是 UNPACK_BY_LENGTH_FIELD

3. 当前项目中的拆包配置

配置位于 server/tcp_server.cppHTcpServer 构造函数中:

cpp 复制代码
HTcpServer::HTcpServer(int port, int thread_num) {
  memset(&protorpc_unpack_setting_, 0, sizeof(unpack_setting_t));
  protorpc_unpack_setting_.mode = UNPACK_BY_LENGTH_FIELD;
  protorpc_unpack_setting_.package_max_length = DEFAULT_PACKAGE_MAX_LENGTH;
  protorpc_unpack_setting_.body_offset = RUNTIME_MSG_HEADER_SIZE;
  protorpc_unpack_setting_.length_field_offset =
      PROTORPC_HEAD_LENGTH_FIELD_OFFSET;
  protorpc_unpack_setting_.length_field_bytes =
      PROTORPC_HEAD_LENGTH_FIELD_BYTES;
  protorpc_unpack_setting_.length_field_coding = ENCODE_BY_BIG_ENDIAN;
  setUnpack(&protorpc_unpack_setting_);
  port_ = port;
  setThreadNum(thread_num);
  onMessage = std::bind(&HTcpServer::HandleMessage, this, std::placeholders::_1,
                        std::placeholders::_2);
}

相关宏定义位于 src/rpc_message.h

cpp 复制代码
#define RUNTIME_MSG_HEADER_SIZE (16)
#define JSON_STRING_MINIMUM_SIZE (2)
#define RUNTIME_MSG_HEADER_PRIFIX (0xABCDDCBA)
#define PROTORPC_HEAD_LENGTH_FIELD_OFFSET (12)
#define PROTORPC_HEAD_LENGTH_FIELD_BYTES (4)

也就是说,当前协议使用 16 字节固定头,长度字段从偏移 12 开始,占 4 字节,并按大端解析。

4. 当前协议包格式

RpcMessage 的二进制协议头结构如下:

text 复制代码
0       4       8      10     12       16
+-------+-------+------+------+--------+
| magic | reqid | cmd  | type | length |
+-------+-------+------+------+--------+
                                         4 bytes, big endian
+----------------------------------------------------------+
| payload, length bytes                                    |
+----------------------------------------------------------+

字段含义:

  • magic:4 字节协议标识,当前为 0xABCDDCBA
  • reqid:4 字节请求 ID。
  • cmd:2 字节命令。
  • type:2 字节消息类型。
  • length:4 字节 payload 长度,大端编码。
  • payload:长度为 length 的消息体。

因此,一个完整包长度为:

text 复制代码
package_len = RUNTIME_MSG_HEADER_SIZE + length
package_len = 16 + length

5. libhv 长度字段拆包算法

libhv 的 UNPACK_BY_LENGTH_FIELD 核心逻辑可以概括为:

text 复制代码
head_len = body_offset
body_len = read(length_field_offset, length_field_bytes, length_field_coding)
package_len = head_len + body_len + length_adjustment

映射到当前项目:

text 复制代码
head_len = 16
body_len = 从 offset 12 读取 4 字节大端整数
length_adjustment = 0
package_len = 16 + body_len

libhv 每次收到数据后,会把新数据追加到连接自己的读缓存中,然后循环尝试切包:

text 复制代码
while remain >= body_offset:
    读取 length 字段
    计算 package_len = 16 + length

    if remain >= package_len:
        回调业务层 onMessage,一个完整包处理完成
        移动指针,继续判断剩余数据中是否还有完整包
    else:
        数据不足一个完整包,停止处理,缓存剩余数据等待下一次读取

6. 粘包处理过程

如果一次 TCP read 读到了多个完整包,例如:

text 复制代码
[packet1][packet2][packet3]

libhv 会在内部循环中依次计算每个包的长度,只要缓存中的剩余数据满足一个完整包长度,就调用一次业务回调。

因此业务层会看到 3 次 onMessage 回调:

text 复制代码
onMessage(packet1)
onMessage(packet2)
onMessage(packet3)

业务代码不需要自己再从一个大 buffer 中拆出多个包。

7. 拆包/半包处理过程

如果一次 TCP read 只读到了半个包,例如:

text 复制代码
第一次读取: [header + half payload]
第二次读取: [remaining payload]

libhv 在第一次读取后会发现:

text 复制代码
remain < package_len

此时它不会触发业务回调,而是把未处理的数据保留在连接读缓存中。第二次读到剩余 payload 后,libhv 会继续用缓存中的旧数据加新数据计算完整包长度。只有当数据满足完整包长度后,才触发 onMessage

因此业务层不会收到不完整包。

8. 业务层收到的数据

当前项目的业务回调位于 src/tcp_server.cpp

cpp 复制代码
void HTcpServer::HandleMessage(const TSocketChannelPtr& socketChannel,
                               Buffer* buff) {
  RpcMessage rpc_msg;
  memset(&rpc_msg, 0, sizeof(rpc_msg));
  int packlen = rpc_msg.protorpc_unpack(buff->data(), buff->size());
  if (packlen < 0) {
    LOG(ERROR) << "protorpc_unpack failed!";
    return;
  }

  if (message_callback_ != nullptr) {
    message_callback_(socketChannel, rpc_msg);
  }
}
  ...
}

这里的 buff 理论上已经是 libhv 按 UNPACK_BY_LENGTH_FIELD 切好的一个完整包。RpcMessage::protorpc_unpack 的职责是解析业务协议字段,而不是处理 TCP 层的粘包或半包。

RpcMessage::protorpc_unpack 会再次读取协议头中的 length_,并计算业务包长度:

cpp 复制代码
int RpcMessage::protorpc_package_length(const RpcMessage* msg) {
  return RUNTIME_MSG_HEADER_SIZE + msg->length_;
}

如果 len < packlen,会返回错误。但正常情况下,这种半包已经被 libhv 缓存层挡住,不应进入业务回调。

9. 包长保护

当前配置中:

cpp 复制代码
protorpc_unpack_setting_.package_max_length = DEFAULT_PACKAGE_MAX_LENGTH;

libhv 默认最大包长是 2MB:

cpp 复制代码
#define DEFAULT_PACKAGE_MAX_LENGTH  (1 << 21)

如果根据长度字段计算出来的 package_len 超过 package_max_length,libhv 会认为包长度异常,设置错误并关闭连接。这可以避免异常长度字段导致读缓存无限扩容。

10. 注意事项

  • unpack_setting_t 的生命周期必须长于连接使用周期。当前项目把 protorpc_unpack_setting_ 作为 HTcpServer 成员变量保存,这是合适的,不能把它定义成构造函数里的局部变量后传给 setUnpack
  • length_field_offset + length_field_bytes 必须小于或等于 body_offset。当前项目为 12 + 4 == 16,正好等于协议头长度。
  • length_field_coding 必须和打包函数保持一致。当前 RpcMessage::protorpc_pack 使用大端方式写入 length_,拆包配置也使用 ENCODE_BY_BIG_ENDIAN,两者一致。
  • length 字段表示 payload 长度,不包含 16 字节协议头,因此 length_adjustment 保持默认值 0 即可。

11. 总结

libhv 不是依赖 TCP read 次数来判断消息边界,而是根据 setUnpack 配置维护连接级读缓存并执行应用层协议切分。

在当前项目中,完整包长度由 16 字节协议头中的 4 字节 length 字段决定:

text 复制代码
完整包长度 = 16 字节协议头 + length 字节 payload

当数据不足一个完整包时,libhv 会缓存等待;当数据包含多个完整包时,libhv 会循环拆出多个包并多次回调业务层。因此,JTcpServer::HandleMessage 中收到的 Buffer* buff 应当已经是一条完整的 RpcMessage

Reference

基于libhv实现的TCP Client & Server支持同步,异步传输 (C++11)

相关推荐
春蕾夏荷_7282977251 个月前
libhv vs2019 udp简单的实例
网络·udp·libhv·结构体
闻道且行之2 个月前
C/C++ HTTP 服务:常用方法与实现方式全解析
c语言·c++·http·libhv·curl·mongoose·libcurl
闻道且行之2 个月前
libhv 安装与使用全流程教程
c++·http·socket·libhv·c/c++
飞剑神4 个月前
libhv HttpServer 路由函数定义
libhv
fengbingchun1 年前
线性规划饮食问题求解:FastAPI作为服务端+libhv作为客户端实现
fastapi·libhv·pyomo
CAir21 年前
HTTP SSE 实现
http·libhv·sse
CAir23 年前
libhv之hio_t分析
libhv·源码分析·hio_t
CAir23 年前
libhv之hloop源码分析
c++·libhv·hloop