技术: TCP流解析(粘包和拆包问题)

也就是集中探讨一下粘包和分包问题, message or package;sub and adhere package.

TCP是字节流(流式协议, 无边界), 不知道消息(包, 有边界)的概念. 它本身不知道边界, 它不保留边界.

原因阐述

TCP程序中涉及的粘包和分包:

  • 粘包是因为TCP会缓存小的数据包而不是马上发出去(但是TCP没有包意识, 它也不管), 攒够多了之后再一起发出去.
    (但这给一些实时性要求比较高的应用造成影响)
  • 分包由于接收端缓存问题(或者网络问题), 也会将大的数据段拆分发送;

试想如果它知道消息message的概念, 估计就会整段完整的发了; 但这不现实, 也和协议的设计不符.

TCP粘包和分包的问题核心并不在TCP本身(TCP根本不知道包), 而在如何把字节流切开成一个个的消息(一般业务都是基于报文的).

实质上是流解析包解析的互换, 将无边界的数据流解析为有边界的数据包.

分包和粘包处理要一起做(不能只写发送端, 你要考虑接收端怎么处理)

有人喜欢直接发送C语言结构体, 但是这个要考虑对齐问题. 有人的做法是这样的, 直接用 pragra 命令设置全局的pack, 然后发送, 这样导致的后果就是, 没有考虑ABI接口, 即库兼容问题; 当然从而导致API兼容性, 很可能在接收端直接core dump.

并且, 使用结构体发送, 升级也是个大难题, 后续版本增删字段, 问你怎么办?

模拟问题&解决

TCP是流式协议, 所以在使用的时候, 一般会进行报文设计; 要么你使用别人设计好的报文协议, 要么你自己设计.
如果你自己不设计, 也不使用别人设计好的, 那么你肯定错了. 一定会遇到所谓的粘包和拆包.

现代的一些RPC或者网络框架很多都已经处理了该问题, 我知道的, 比较好的方式有 protobuffer, thrift.

前辈的建议是:

我的建议是,不要自己去实现,应该去使用别人已经写好的代码

但是实际上, 处理TCP粘包和拆包的核心逻辑没有那么复杂(真正实现的工程细节, 考虑的错误, 效率问题等比较复杂), 主要处理以下两种情况:

  • 拆包情况怎么处理, 比如: 一个长数据经过多次才到达目的地
  • 粘包情况怎么处理, 比如: 多个数据一次性发送

但是不管哪种情况, 接收端处理时都需要服务端配合, 先同意协商好, 最简单的例子, 比如, 先协商发送完整message的长度, 为9个字节, 那么每次读取的时候, 先读前9个字节确定后面数据的长度, 核心代码如下:
写端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
printf("emulate_adheringpackage...\n");  
const int HEAD_SIZE = 9;
char buf[1024] = {0};
char text[128] = {0};
char *pstart = buf;

// append text
memset(text, 0, sizeof(text));
snprintf(text, sizeof(text), "Hello ");
snprintf(pstart, HEAD_SIZE, "%08zu", strlen(text) + 1);
pstart += HEAD_SIZE; //先写的9个字节数据头, 记录实际消息的大小
snprintf(pstart, strlen(text) + 1, "%s", text);
pstart += strlen(text) + 1;

// append text
memset(text, 0, sizeof(text));
snprintf(text, sizeof(text), "I'm lucky.");
snprintf(pstart, HEAD_SIZE, "%08zu", strlen(text) + 1);
pstart += HEAD_SIZE; //先写的9个字节数据头, 记录实际消息的大小
snprintf(pstart, strlen(text) + 1, "%s", text);
pstart += strlen(text) + 1;

读端, 先读前9个字节:(不管对端分几次发, 总是先读9个字节之后在读数据; 这是一次完整的读取)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int datasize = 0;  
const int HEAD_SIZE = 9;
char buf[512] = {0};
while (true) {
memset(buf, 0, sizeof(buf));
if (! readPack(sock, buf, HEAD_SIZE)) { //先读9个字节的长度
printf("read head buffer failed.\n");
safe_close(sock);
return;
}

datasize = atoi(buf); //已经读取到了数据的长度信息
printf("data size: %s, value:%d\n", buf, datasize);
memset(buf, 0, sizeof(buf));
if (! readPack(sock, buf, datasize)) {
printf("read data buffer failed\n");
safe_close(sock);
return;
}
printf("data size: %d, text: %s\n", datasize, buf);
if (0 == strcmp(buf, "exit")) {
break;
}
}

也就是确保一个完整的消息总是有一个保存长度信息的头, 固定长度的头. 之后无论一个完整的消息被拆分发送还是被粘包发送, 消息头确保了边界, 包的完整性.

其中 readPack的实现, 可以根据具体的IO情况分别实现, 比如如果用阻塞IO的话, 最好写成readn这种情况, 参考如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**  
* read size of len from sock into buf.
*/
bool readPack(int sock, char* buf, size_t len) {
if (NULL == buf || len < 1) {
return false;
}
memset(buf, 0, len); // only reset buffer len.
ssize_t read_len = 0, readsum = 0;
do {
read_len = read(sock, buf + readsum, len - readsum);
if (-1 == read_len) { // ignore error case
return false;
}
printf("receive data: %s\n", buf + readsum);
readsum += read_len;
} while (readsum < len && 0 != read_len);
return true;
}

(异步IO的读取情况, 更加复杂; 具体问题再说)

完整额代码如下, 请参考 net_work_life

常见实用解决方案

业务层本意上是想使用一种基于报文的协议, 但所定义的报文格式并没有提供报文分隔符或者长度字段, 这就要求程序进行语义分析, 增加了实现难度. 所以一般是要自己设计报文格式, 以应对相关的流解析问题.

基于TCP socket的程序, 有几种方式可用来实现报文协议:

  1. 报文中声明报文数据的长度.
  2. 使用分隔符. (例如以行作为分隔符)
  3. 发送方发送完一个报文后关闭连接(这个不推荐, 这是HTTP1.0的方式)

下面还有一些经典的实践:

  • 读,写一般都要循环监督(除了fgets/fputs, 因为它已经为你封装好了循环)
  • 字节序当做数组, 那么组织报文的时候最好加上\0. (虽然C++的字符串没有这个要求)
    最好不要ptr[strlen(ptr)] = '\0';, 因为strlen的判断标准就是\0, 还是老老实实用具体的指定长度去加上\0

其他问题

读取的报文格式不对, 或者消息&数据错误, 可能有多方面原因:

  • 报文的组织格式等有问题(数据是完整的, 解析流出了问题)
  • 边界失效, 即数据本身都不完整(TCP可保证到达顺序与正确性,即保证已收到的数据的正确)
    (特别注意一下, 边界失效问题: TCP传输处理粘包以外还需要处理丢包情况, 丢包通常是发送端发送失败导致数据流不完整引起的边界失效)

总结

说白了就是一个网络流(IO), 字节解析问题, 搞了一整篇文章…博主已经去跳海了.

文章目录
  1. 1. 原因阐述
  2. 2. 模拟问题&解决
  3. 3. 常见实用解决方案
  4. 4. 其他问题
  5. 5. 总结
|