你真的理解粘包与半包吗?3分钟搞懂它

通俗的例子

这里先举个可能不太恰当,但是很容易理解的例子。

比如,平时我们要寄快递,如果东西太大的话,那么就需要拆成几个包裹来邮寄。

收件人仅收到个别包裹的时候,东西是不完整的,对应到网络传输中,这种情况就叫半包。

只有等接收到全部包裹时,这个东西(传输的信息)才完整,所以半包情况下无法解析出完整的数据,需要等,等接收到全部包裹。

那么问题来了,如何知晓已经收到全部包裹了呢?下文我们再作分析。

再比如,快过年了,我打算给家里的亲戚送点礼物,给每位长辈送个手表,我们都知道手表的体积不大,并且我家里人都住在一个村,所以把给各长辈的礼物打包在一个包裹里邮寄,这样能节省运费。

这种把本应该分多个包传输的数据合成一个包发送的情况,对应到网络传输中,就叫粘包。

看完这个例子之后,应该对粘包与半包有点感觉了,接下来我们看下网络中实际的情况。

实际情况

粘包与半包只有在 TCP 传输的时候才会有,像 UDP 是不会有这种情况的,原因是因为 TCP 是面向流的,数据之间没有界限的,而 UDP 是有的界限的。

如果熟悉 TCP 和 UDP 报文格式的同学肯定知道,TCP 的包没有报文长度,而 UDP 的包有报文长度,这也说明了 TCP 为什么是流式。

所以我为什么说上面的例子不太恰当,因为现实生活中快递的包裹之间其实是有界限的,TCP 则像流水,没有明确的界限。

然后 TCP 有发送缓冲区的概念,UDP 实际上是没这个概念。

假设 TCP 一次传输的数据大小超过发送缓冲区大小,那么一个完整的报文就需要被拆分成两个或更多的小报文,这可能会产生半包的情况,当接收端收到不完整的数据,是无法解析成功的。

​如果 TCP 一次传输的数据大小小于发送缓冲区,那么可能会跟别的报文合并起来一块发送,这就是粘包。

此时接收端也无法正常解析报文,需要将其拆成多个正确的报文,才能正常解析。

关于粘包与半包,我还看到有拿 MTU (最大传输单元)说事的,如果发送的数据大于 MTU 那就会出现拆包,导致半包的情况。

我个人觉得这里有点不对,简单理解下,UDP 也是要遵循 MTU 的呀,对吧?那它咋不会发生半包呢?

我们接着来看如何解决粘包与半包。

那如何解决粘包与半包问题呢?

  • 粘包:这个思路其实很清晰,就是把它拆开呗,具体就是看怎么拆了,比如我们可以固定长度,我们规定每个包都是10个字节,那么就10个字节切一刀,这样拆开解析就 ok 了。

  • 半包:半包其实就是信息还不完整,我们需要等接收到全部的信息之后再作处理,当我们识别这是一个不完整的包时候,我们先 hold 住,不作处理,等待数据完整再处理。这里关键点在于,我们如何才能知道此时完整了?上面说的固定长度其实也是一点,当然还有更多更好的解决方案,我们接着往下看。

实际常见解决粘包与半包问题有三个方案:

  • 固定长度

  • 分隔符

  • 固定长度字段+内容

为了说明方便,以下没有按二进制的位等单位来描述。

【文章福利】另外小编还整理了一些C++后端开发面试题,教学视频,后端学习路线图免费分享,需要的可以自行添加:学习交流群点击加入~ 群文件共享

小编强力推荐C++后端开发免费学习地址:C/C++Linux服务器开发高级架构师/C++后台开发架构师​icon-default.png?t=M5H6https://ke.qq.com/course/417774?flowToken=1013189

固定长度

这个其实很简单,比如现在要传输 ABC、EF 这两个包,如果不做处理接收端很可能收到的是 AB、CEF 或者 ABCE、F 等等。

这时候我们固定长度,我们规定每个报文长度都是 3,如果一个报文实际数据不足 3,那么就用空字符填充一下 。

所以我们发送的报文是 :

​接收到的情况可能是:

​但我们是按照 3 位来处理的,所以一次只会按照 3 位来解析,所以第一次虽然收到的数据是 ABCE,但我们就解析 3 位,即解析出 ABC,留着了个 E,等我们要继续解析 3 位的时候,发现长度不足 3,所以我们暂时先不管,先等等。

后面等到了 F“”,我们发现当下数据又满足 3 位了,所以我们接着解析 EF“” 。

这样就解决了粘包与半包问题。

对应到 Netty 中的实现就是 FixedLengthFrameDecoder,这个类来实现固定长度的解码。

核心逻辑就是我上面说的,我们来看下源码,很简单:

​固定长度的优点:简单。

缺点:固定长度很僵硬,不易于扩展,且如果设置过大来满足业务场景的话,会导致空间浪费,因为不足长度的需要填充。

分隔符

这个应该很好理解, 还是拿 ABC、EF 这两个包举例,我在写完 ABC后,插入一个分号,组成ABC;,EF 同理:

​这样以分隔符为界限来切分无界限的 TCP 流,来解决粘包与半包问题,这个应该很好理解,既然你 TCP 没界限,我业务上给你搞个界限。

对应到 Netty 中的实现就是 DelimiterBasedFrameDecoder,具体源码就不贴了,有点长,不过道理还是简单的。

一直解析,等识别到分隔符之后,说明前面的数据完整了,于是解析前面的数据,然后继续往后扫描解析。

分隔符的优点:简单,也不会浪费空间。

缺点:需要对内容本身进行处理,防止内容内出现分隔符,这样就会导致错乱,所以需要扫描一遍传输的数据将其转义,或者可以用 base64 编码数据,用 64 个之外的字符作为分隔符即可。

分隔符的处理方式在业界也是常用的,比如 Redis 就用换行符来分隔。

固定长度字段+内容

这个也很好理解,比如协议规定固定 4 位存放内容的长度,这样内容就可以伸缩:

还是拿 ABC、EF 这两个包举例:

​解析流程是:先获取 4 位,如果当前收到的数据不够 4 位,那就再等等,够 4 位之后解析得到长度是 3,所以我再往后取 3 位,同样数据如果不够 3 位就再等等,够了的话就解析,这样就获取一个完整的包了。

然后接着往后获取 4 位,解析得到 2,同理根据 2 往后再取 2 位,解析得到 EF。

这种方式就是先解析固定长度的字段,获得后面内容的长度,根据内容长度来获取内容,从而得到一个完整的报文。

对应到 Netty 中的实现就是 LengthFieldBasedFrameDecoder,具体源码就不贴了,有点长,

固定长度字段+内容的优点:可以根据固定字段精准定位,也不用扫描转义字符。

缺点:固定长度字段的设计比较困难,大了浪费空间,毕竟每个报文都带这个长度,小了可能不够用。

总结

好了,我们总结一下。

因为 TCP 是面向流的协议,且利用缓冲区来提高发送的效率,所以会导致粘包/半包情况的发生。

对于这种情况,我们可以在报文上动手脚,可以约定固定长度的报文,或埋入分隔符,或利用固定长度字段+内容等常见的三种方式来解决粘包、半包的问题。

以上三种在 Netty 中都有现成实现类,可直接使用:

  • FixedLengthFrameDecoder,固定长度

  • DelimiterBasedFrameDecoder,分隔符

  • LengthFieldBasedFrameDecoder,定长度字段+内容

建议实验一下,会有更清晰的认识。

最后

好了,关于粘包与半包的内容就写到这了,关于源码就不分析了,思路比较重要。

参考资料

​推荐一个零声教育C/C++后台开发的免费公开课程,个人觉得老师讲得不错,分享给大家:C/C++后台开发高级架构师,内容包括Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,立即学习

原文:为什么网络 I/O 会被阻塞?

  • 0
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
非常抱歉,我漏掉了处理粘包问题。在WebSocket中,由于数据没有明确的边界,因此可能会出现粘包的情况。为了解决这个问题,我们可以在接收到消息时进行缓存,等待下一次消息一起处理。 下面是修改过的代码: ``` using UnityEngine; using WebSocketSharp; using ProtoBuf; using System.IO; public class WebSocketClient : MonoBehaviour { private WebSocket webSocket; private MemoryStream receiveStream = new MemoryStream(); // 接收缓存 private void Start() { webSocket = new WebSocket("ws://127.0.0.1:8080"); // 这里是WebSocket服务器的地址和端口 webSocket.OnMessage += OnMessage; webSocket.Connect(); } private void OnMessage(object sender, MessageEventArgs e) { receiveStream.Write(e.RawData, 0, e.RawData.Length); // 检查是否有完整的消息 while (TryReadMessage(out var message)) { // 处理接收到的消息 } } private bool TryReadMessage(out MyMessage message) { message = null; if (receiveStream.Length < 4) // 消息头长度为4字节 { return false; } // 读取消息头 var header = new byte[4]; receiveStream.Read(header, 0, 4); var msgLength = BitConverter.ToInt32(header, 0); if (receiveStream.Length < msgLength + 4) // 消息体长度为msgLength { receiveStream.Seek(-4, SeekOrigin.Current); // 将指针移回消息头 return false; } // 读取消息体 var msgBody = new byte[msgLength]; receiveStream.Read(msgBody, 0, msgLength); using (var stream = new MemoryStream(msgBody)) { // 使用protobuf反序列化消息 message = Serializer.Deserialize<MyMessage>(stream); } return true; } } [ProtoContract] public class MyMessage { [ProtoMember(1)] public int Id { get; set; } [ProtoMember(2)] public string Content { get; set; } } ``` 在上面的代码中,我们使用了一个MemoryStream作为缓存,每次接收到消息时将消息写入缓存中。在TryReadMessage方法中,我们首先检查缓存中是否有足够的字节可以读取消息头,如果没有,则等待下一次消息一起处理。如果有足够的字节,则读取消息头,并检查缓存中是否有足够的字节可以读取消息体,如果没有,则将指针移回消息头,等待下一次消息一起处理。如果有足够的字节,则读取消息体,并使用protobuf反序列化出消息对象。最后返回true表示读取成功,返回false表示需要等待下一次消息。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值