TCP粘包的那点事儿

Nagle算法带来的弊端-粘包

Posted by 夏敏的博客 on August 28, 2017

提起大名鼎鼎的TCP,其特性我们大家都是倒背如流,TCP是面向连接的,UDP是面向非连接的。那么在通信中我们每次发送的帧数是有大小限制的,这个大小受很多影响,当然是木桶效应,我们的传输单元有大小限制。

最大传输单元MTU

根据宽带连接方式的不同,MTU可能不尽相同,如下所示,单位为字节数:

  1. PPPoE/ADSL: 1360-1492
  2. PPTP VPN: 1400-1460
  3. L2TP VPN: 1400-1460
  4. Fixed IP: 1400-1500
  5. DHCP: 1400-1492

既然有最大传输单元的限制,那么我们一次发送的数据最好小于 MTU的限制数-IP的头部大小-网络协议头大小
如果一下子写入超出最大传输数据的限制,那么我们的数据便会分开以2帧或者更多的发出去。当然Java来说,底层已经封装好了,我们可以一下子写很大的数据没有问题。

但是对于我们写入的数据量很小呢,假设我们每次发1字节,发1000次,那是否会以1000帧的形式发送呢,并不会,因为TCP使用Nagle算法进行了优化

Nagle算法

Nagle算法于1984年定义为福特航空和通信公司IP/TCP拥塞控制方法,这使福特经营的最早的专用TCP/IP网络减少拥塞控制,从那以后这一方法得到了广泛应用。Nagle的文档里定义了处理他所谓的小包问题的方法,这种问题指的是应用程序一次产生一字节数据,这样会导致网络由于太多的包而过载(一个常见的情况是发送端的”糊涂窗口综合症(Silly Window Syndrome)”)。从键盘输入的一个字符,占用一个字节,可能在传输上造成41字节的包,其中包括1字节的有用信息和40字节的首部数据。这种情况转变成了4000%的消耗,这样的情况对于轻负载的网络来说还是可以接受的,但是重负载的福特网络就受不了了,它没有必要在经过节点和网关的时候重发,导致包丢失和妨碍传输速度。吞吐量可能会妨碍甚至在一定程度上会导致连接失败。Nagle的算法通常会在TCP程序里添加两行代码,在未确认数据发送的时候让发送器把数据送到缓存里。任何数据随后继续直到得到明显的数据确认或者直到攒到了一定数量的数据了再发包。

也就是数据区有缓冲,我们发送的数据并不是一定会以每帧的形式立即发出去。 那么这样的情况,就带来了新的问题 - 粘包

粘包

TCP 粘包是指发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。

出现粘包现象的原因是多方面的,它既可能由发送方造成,也可能由接收方造成。

  1. 如果利用 tcp 每次发送数据,就与对方建立连接,然后双方发送完一段数据后,就关闭连接,这样就不会出现粘包问题(因为只有一种包结构, 类似于 http 协议)。
    关闭连接主要是要双方都发送 close 连接(参考 tcp 关闭协议)。如:A 需要发送一段字符串给 B,那么 A 与 B 建立连接,然后发送双方都默认好的协议字符如 “hello world”,然后 B 收到报文后,就将缓冲区数据接收,然后关闭连接,这样粘包问题不用考虑到,因为大家都知道是发送一段字符。

  2. 如果发送数据无结构,如文件传输,这样发送方只管发送,接收方只管接收存储就 ok,也不用考虑粘包 3 如果双方建立连接,需要在连接后一段时间内发送不同结构数据,如连接后,有好几种结构, 比如: CMD + Protocol + Content的结构。 那么就容易出现问题了。

    1. 发送端需要等缓冲区满才发送出去,造成粘包
    2. 接收方无法及时接收缓冲区的包,造成多个包接收

对于粘包的情况发生,我们有多种解决方案:

  1. 对于发送方引起的粘包现象,用户可通过编程设置来避免,TCP提供了强制数据立即传送的操作指令push,TCP软件收到该操作指令后,就立即将本段数据发送出去,而不必等待发送缓冲区满;
  2. 对于接收方引起的粘包,则可通过优化程序设计、精简接收进程工作量、提高接收进程优先级等措施,使其及时接收数据,从而尽量避免出现粘包现象;
  3. 由接收方控制,将一包数据按结构字段,人为控制分多次接收,然后合并,通过这种手段来避免粘包。

对于以上的三种方案,都有其弊端

  1. 没有了优化算法,影响性能,不能说为了防止粘包就不要Nagle算法了
  2. 第二个方案不靠谱,不是完整解决方案,只能是尽量避免出现粘包
  3. 人为控制分多次接收,操作麻烦,而且效率低下,可能需要多次协调

比较靠谱的一个方案是,接收方创建一预处理线程,对接收到的数据包进行预处理,自己进行拆包。

为什么基于 TCP 的通讯程序需要进行封包和拆包

TCP 是个 “流” 协议,所谓流,就是没有界限的一串数据,大家可以想想河里的流水,是连成一片的,其间是没有分界线的。但一般通讯程序开发是需要定义一个个相互独立的数据包的,比如用于登陆的数据包,用于注销的数据包。由于 TCP”流” 的特性以及网络状况,在进行数据传输时会出现以下几种情况。

假设我们连续调用两次 send 分别发送两段数据 data1 和 data2, 在接收端有以下几种接收情况 (当然不止这几种情况, 这里只列出了有代表性的情况).

A. 先接收到 data1, 然后接收到 data2.
B. 先接收到 data1 的部分数据, 然后接收到 data1 余下的部分以及 data2 的全部.
C. 先接收到了 data1 的全部数据和 data2 的部分数据, 然后接收到了 data2 的余下的数据.
D. 一次性接收到了 data1 和 data2 的全部数据.

对于 A 这种情况正是我们需要的, 不再做讨论. 对于 B,C,D 的情况就是大家经常说的 “粘包”, 就需要我们把接收到的数据进行拆包,拆成一个个独立的数据包,为了拆包就必须在发送端进行封包。

另:对于 UDP 来说就不存在拆包的问题, 因为 UDP 是个 “数据包” 协议, 也就是两段数据间是有界限的,在接收端要么接收不到数据要么就是接收一个完整的一段数据,不会少接收也不会多接收。

对于包的封装

一般有3种解决方案:

  1. 发送固定长度的消息
  2. 把消息的尺寸与消息一块发送
  3. 使用特殊标记来区分消息间隔

方案一比较死板,固定长度的数据不够方便,方案2相对灵活,将长度放到传输的头里面,可以灵活控制长度,方案三若数据内容中有数据和特殊标记重合则会出错。

对于包的拆解

解包的方式采用缓冲区方式,

作者:Anderson大码渣,欢迎关注我的简书: Anderson大码渣