也就是集中探讨一下粘包和分包问题, 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 | printf("emulate_adheringpackage...\n"); |
读端, 先读前9个字节:(不管对端分几次发, 总是先读9个字节之后在读数据; 这是一次完整的读取)
1 | int datasize = 0; |
也就是确保一个完整的消息总是有一个保存长度信息的头, 固定长度的头. 之后无论一个完整的消息被拆分发送还是被粘包发送, 消息头确保了边界, 包的完整性.
其中 readPack的实现, 可以根据具体的IO情况分别实现, 比如如果用阻塞IO的话, 最好写成readn
这种情况, 参考如下:
1 | /** |
(异步IO的读取情况, 更加复杂; 具体问题再说)
完整额代码如下, 请参考 net_work_life
常见实用解决方案
业务层本意上是想使用一种基于报文的协议, 但所定义的报文格式并没有提供报文分隔符或者长度字段, 这就要求程序进行语义分析, 增加了实现难度. 所以一般是要自己设计报文格式, 以应对相关的流解析问题.
基于TCP socket的程序, 有几种方式可用来实现报文协议:
- 报文中声明报文数据的长度.
- 使用分隔符. (例如以行作为分隔符)
- 发送方发送完一个报文后关闭连接(这个不推荐, 这是HTTP1.0的方式)
下面还有一些经典的实践:
- 读,写一般都要循环监督(除了fgets/fputs, 因为它已经为你封装好了循环)
- 字节序当做数组, 那么组织报文的时候最好加上
\0
. (虽然C++的字符串没有这个要求)
最好不要ptr[strlen(ptr)] = '\0';
, 因为strlen
的判断标准就是\0
, 还是老老实实用具体的指定长度去加上\0
其他问题
读取的报文格式不对, 或者消息&数据错误, 可能有多方面原因:
- 报文的组织格式等有问题(数据是完整的, 解析流出了问题)
- 边界失效, 即数据本身都不完整(TCP可保证到达顺序与正确性,即保证已收到的数据的正确)
(特别注意一下, 边界失效问题: TCP传输处理粘包以外还需要处理丢包情况, 丢包通常是发送端发送失败导致数据流不完整引起的边界失效)
总结
说白了就是一个网络流(IO), 字节解析问题, 搞了一整篇文章…博主已经去跳海了.