5.0 TCP粘包、拆包与通信协议

2017-02-07 22:20:27 11,057 1

在TCP编程中,通常Sever端与Client通信时的消息都有着固定的消息格式,称之为协议(protocol),例如FTP协议、Telnet协议等,有的公司也会自己开发协议。

那么协议到底是干什么的呢?说白了,协议了就是定义了数据通信的格式。主要是为了解决TCP编程中的粘包和半包问题。

由于TCP(transport control protocol,传输控制协议)是面向连接的,面向流的,提供高可靠性服务。收发两端(客户端和服务器端)都要有一一成对的socket,因此,发送端为了将多个发往接收端的包,更有效的发到对方,使用了优化方法(Nagle算法),将多次间隔较小且数据量小的数据,合并成一个大的数据块,然后进行封包。这样,接收端,就难于分辨出来了,必须提供科学的拆包机制。即面向流的通信是无消息保护边界的

UDP(user datagram protocol,用户数据报协议)是无连接的,面向消息的,提供高效率服务。不会使用块的合并优化算法,, 由于UDP支持的是一对多的模式,所以接收端的skbuff(套接字缓冲区)采用了链式结构来记录每一个到达的UDP包,在每个UDP包中就有了消息头(消息来源地址,端口等信息),这样,对于接收端来说,就容易进行区分处理了。 即面向消息的通信是有消息保护边界的

由于TCP无消息保护边界, 需要在消息接收端处理消息边界问题,也就是我们所说的粘包、拆包问题;而UDP通信则不需要考虑此问题。

1 TCP粘包、拆包图解

5F0454F5-BAE3-42A7-8C43-DB1BA71A532B.png


假设客户端分别发送了两个数据包D1和D2给服务端,由于服务端一次读取到字节数是不确定的,故可能存在以下四种情况:

  • 服务端分两次读取到了两个独立的数据包,分别是D1和D2,没有粘包和拆包

  • 服务端一次接受到了两个数据包,D1和D2粘合在一起,称之为TCP粘包

  • 服务端分两次读取到了数据包,第一次读取到了完整的D1包和D2包的部分内容,第二次读取到了D2包的剩余内容,这称之为TCP拆包

  • 服务端分两次读取到了数据包,第一次读取到了D1包的部分内容D1_1,第二次读取到了D1包的剩余部分内容D1_2和完整的D2包。

特别要注意的是,如果TCP的接受滑窗非常小,而数据包D1和D2比较大,很有可能会发生第五种情况,即服务端分多次才能将D1和D2包完全接受,期间发生多次拆包。


2 粘包、拆包发生原因

笔者个人理解,粘包、拆包问题的产生原因有以下3种:

  • socket缓冲区与滑动窗口

  • MSS/MTU限制

  • Nagle算法

2.1 socket缓冲区与滑动窗口

先明确一个概念:每个TCP socket在内核中都有一个发送缓冲区(SO_SNDBUF )和一个接收缓冲区(SO_RCVBUF),TCP的全双工的工作模式以及TCP的滑动窗口便是依赖于这两个独立的buffer以及此buffer的填充状态。SO_SNDBUF和SO_RCVBUF 在windows操作系统中默认情况下都是8K

SO_SNDBUF

进程发送的数据的时候(假设调用了一个send方法),最简单情况(也是一般情况),将数据拷贝进入socket的内核发送缓冲区之中,然后send便会在上层返回。换句话说,send返回之时,数据不一定会发送到对端去(和write写文件有点类似),send仅仅是把应用层buffer的数据拷贝进socket的内核发送buffer中。

SO_RCVBUF

把接受到的数据缓存入内核,应用进程一直没有调用read进行读取的话,此数据会一直缓存在相应socket的接收缓冲区内。再啰嗦一点,不管进程是否读取socket,对端发来的数据都会经由内核接收并且缓存到socket的内核接收缓冲区之中。read所做的工作,就是把内核缓冲区中的数据拷贝到应用层用户的buffer里面,仅此而已。


滑动窗口

TCP链接在三次握手的时候,会将自己的窗口大小(window size)发送给对方,其实就是SO_RCVBUF指定的值。之后在发送数据的时,发送方必须要先确认接收方的窗口没有被填充满,如果没有填满,则可以发送。

每次发送数据后,发送方将自己维护的对方的window size减小,表示对方的SO_RCVBUF可用空间变小。

当接收方处理开始处理SO_RCVBUF 中的数据时,会将数据从socket 在内核中的接受缓冲区读出,此时接收方的SO_RCVBUF可用空间变大,即window size变大,接受方会以ack消息的方式将自己最新的window size返回给发送方,此时发送方将自己的维护的接受的方的window size设置为ack消息返回的window size。

此外,发送方可以连续的给接受方发送消息,只要保证对方的SO_RCVBUF空间可以缓存数据即可,即window size>0。当接收方的SO_RCVBUF被填充满时,此时window size=0,发送方不能再继续发送数据,要等待接收方ack消息,以获得最新可用的window size。

现在来看一下SO_RCVBUF和滑动窗口是如何造成粘包、拆包的?

粘包:假设发送方的每256 bytes表示一个完整的报文,接收方由于数据处理不及时,这256个字节的数据都会被缓存到SO_RCVBUF中。如果接收方的SO_RCVBUF中缓存了多个报文,那么对于接收方而言,这就是粘包。

拆包:考虑另外一种情况,假设接收方的window size只剩了128,意味着发送方最多还可以发送128字节,而由于发送方的数据大小是256字节,因此只能发送前128字节,等到接收方ack后,才能发送剩余字节。这就造成了拆包。

2.2 MSS和MTU分片

MSS是MSS是Maximum Segement Size的缩写,表示TCP报文中data部分的最大长度,是TCP协议在OSI五层网络模型中传输层(transport layer)对一次可以发送的最大数据的限制。

MTU最大传输单元是Maxitum Transmission Unit的简写,是OSI五层网络模型中链路层(datalink layer)对一次可以发送的最大数据的限制。

当需要传输的数据大于MSS或者MTU时,数据会被拆分成多个包进行传输。由于MSS是根据MTU计算出来的,因此当发送的数据满足MSS时,必然满足MTU。归根结底:限制一次可发送数据大小的是MTU,MSS只是TCP协议在MTU基础限制的传输层一次可传输的数据的大小。

为了更好的理解,我们先介绍一下在5层网络模型中应用通过TCP发送数据的流程:

0CA9F0A6-0248-4C98-82E1-2731C484A050.png


  • 对于应用层来说,只关心发送的数据DATA,将数据写入socket在内核中的缓冲区SO_SNDBUF即返回,操作系统会将SO_SNDBUF中的数据取出来进行发送。

  • 传输层会在DATA前面加上TCP Header,构成一个完整的TCP报文。

  • 当数据到达网络层(network layer)时,网络层会在TCP报文的基础上再添加一个IP Header,也就是将自己的网络地址加入到报文中。

  • 到数据链路层时,还会加上Datalink Header和CRC。

  • 当到达物理层时,会将SMAC(Source Machine,数据发送方的MAC地址),DMAC(Destination Machine,数据接受方的MAC地址 )和Type域加入。


    可以发现数据在发送前,每一层都会在上一层的基础上增加一些内容,下图演示了MSS、MTU在这个过程中的作用。

0255C024-2979-41E2-8D85-38AA4906A76F.png


MTU是以太网传输数据方面的限制,每个以太网帧都有最小的大小64bytes最大不能超过1518bytes。刨去以太网帧的帧头 (DMAC目的MAC地址48bit=6Bytes+SMAC源MAC地址48bit=6Bytes+Type域2bytes)14Bytes和帧尾 CRC校验部分4Bytes(这个部分有时候大家也把它叫做FCS),那么剩下承载上层协议的地方也就是Data域最大就只能有1500Bytes这个值 我们就把它称之为MTU。

由于MTU限制了一次最多可以发送1500个字节,而TCP协议在发送DATA时,还会加上额外的TCP Header和Ip Header,因此刨去这两个部分,就是TCP协议一次可以发送的实际应用数据的最大大小,也就是MSS。

MSS长度=MTU长度-IP Header-TCP Header

TCP Header的长度是20字节,IPv4中IP Header长度是20字节,IPV6中IP Header长度是40字节,因此:在IPV4中,以太网MSS可以达到1460byte;在IPV6中,以太网MSS可以达到1440byte。

 需要注意的是MSS表示的一次可以发送的DATA的最大长度,而不是DATA的真实长度。发送方发送数据时,当SO_SNDBUF中的数据量大于MSS时,操作系统会将数据进行拆分,使得每一部分都小于MSS,这就是拆包,然后每一部分都加上TCP Header,构成多个完整的TCP报文进行发送,当然经过网络层和数据链路层的时候,还会分别加上相应的内容。

细心的读者会发现,通过wireshark抓包工具的抓取的记录中,TCP在三次握手中的前两条报文中都包含了MSS=65495的字样。这是因为我们的抓包案例的client和server都运行在本地,不需要走以太网,所以不受到以太网MTU=1500的限制。MSS(65495)=MTU(65535)-IP Header(20)-TCP Header(20)。  

linux服务器上输入ifconfig命令,可以查看不同网卡的MTU大小,如下:

[root@www tianshouzhi]# ifconfig
eth0      Link encap:Ethernet  HWaddr 00:16:3E:02:0E:EA 
          inet addr:10.144.211.78  Bcast:10.144.223.255  Mask:255.255.240.0
          UP BROADCAST RUNNING MULTICAST  MTU:1500  Metric:1
          RX packets:266023788 errors:0 dropped:0 overruns:0 frame:0
          TX packets:1768555 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:1000
          RX bytes:12103832054 (11.2 GiB)  TX bytes:138231258 (131.8 MiB)
          Interrupt:164


lo        Link encap:Local Loopback 
          inet addr:127.0.0.1  Mask:255.0.0.0
          UP LOOPBACK RUNNING  MTU:65535  Metric:1
          RX packets:499956845 errors:0 dropped:0 overruns:0 frame:0
          TX packets:499956845 errors:0 dropped:0 overruns:0 carrier:0
          collisions:0 txqueuelen:0
          RX bytes:86145804231 (80.2 GiB)  TX bytes:86145804231 (80.2 GiB)


可以看到,默认情况下,与外部通信的网卡eth0的MTU大小是1500个字节。而本地回环地址的MTU大小为65535,这是因为本地测试时数据不需要走网卡,所以不受到1500的限制。

MTU的大小可以通过类似以下命令修改: 

ip link set eth0 mtu 65535

其中eth0是网卡的名字。

2.3 Nagle算法

TCP/IP协议中,无论发送多少数据,总是要在数据(DATA)前面加上协议头(TCP Header+IP Header),同时,对方接收到数据,也需要发送ACK表示确认。

即使从键盘输入的一个字符,占用一个字节,可能在传输上造成41字节的包,其中包括1字节的有用信息和40字节的首部数据。这种情况转变成了4000%的消耗,这样的情况对于重负载的网络来是无法接受的。

为了尽可能的利用网络带宽,TCP总是希望尽可能的发送足够大的数据。(一个连接会设置MSS参数,因此,TCP/IP希望每次都能够以MSS尺寸的数据块来发送数据)。Nagle算法就是为了尽可能发送大块数据,避免网络中充斥着许多小数据块。

    Nagle算法的基本定义是任意时刻,最多只能有一个未被确认的小段。 所谓“小段”,指的是小于MSS尺寸的数据块,所谓“未被确认”,是指一个数据块发送出去后,没有收到对方发送的ACK确认该数据已收到。

Nagle算法的规则:

  1. 如果SO_SNDBUF中的数据长度达到MSS,则允许发送;

  2. 如果该SO_SNDBUF中含有FIN,表示请求关闭连接,则先将SO_SNDBUF中的剩余数据发送,再关闭;

  3. 设置了TCP_NODELAY=true选项,则允许发送。TCP_NODELAY是取消TCP的确认延迟机制,相当于禁用了Negale 算法。正常情况下,当Server端收到数据之后,它并不会马上向client端发送ACK,而是会将ACK的发送延迟一段时间(假一般是40ms),它希望在t时间内server端会向client端发送应答数据,这样ACK就能够和应答数据一起发送,就像是应答数据捎带着ACK过去。当然,TCP确认延迟40ms并不是一直不变的,TCP连接的延迟确认时间一般初始化为最小值40ms,随后根据连接的重传超时时间(RTO)、上次收到数据包与本次接收数据包的时间间隔等参数进行不断调整。另外可以通过设置TCP_QUICKACK选项来取消确认延迟。

  4. 未设置TCP_CORK选项时,若所有发出去的小数据包(包长度小于MSS)均被确认,则允许发送;

  5. 上述条件都未满足,但发生了超时(一般为200ms),则立即发送。

3 粘包、拆包问题的解决方案:定义通信协议

粘包、拆包问题给接收方的数据解析带来了麻烦。例如SO_RCVBUF中存在了多个连续的完整包(粘包),因为每个包可能都是一个完整的请求或者响应,那么接收方需要能对此进行区分。如果存在不完整的数据(拆包),则需要继续等待数据,直至可以构成一条完整的请求或者响应。

这个问题可以通过定义应用的协议(protocol)来解决。协议的作用就定义传输数据的格式。这样在接受到的数据的时候,如果粘包了,就可以根据这个格式来区分不同的包,如果拆包了,就等待数据可以构成一个完整的消息来处理。目前业界主流的协议(protocol)方案可以归纳如下:

1 定长协议:假设我们规定每3个字节,表示一个有效报文,如果我们分4次总共发送以下9个字节:

   +---+----+------+----+
   | A | BC | DEFG | HI |
   +---+----+------+----+

那么根据协议,我们可以判断出来,这里包含了3个有效的请求报文

   +-----+-----+-----+
   | ABC | DEF | GHI |
   +-----+-----+-----+

2 特殊字符分隔符协议:在包尾部增加回车或者空格符等特殊字符进行分割 。

例如,按行解析,遇到字符\n、\r\n的时候,就认为是一个完整的数据包。对于以下二进制字节流:

   +--------------+
   | ABC\nDEF\r\n |
   +--------------+

那么根据协议,我们可以判断出来,这里包含了2个有效的请求报文

   +-----+-----+
   | ABC | DEF |
   +-----+-----+

3 长度编码:将消息分为消息头和消息体,消息头中用一个int型数据(4字节),表示消息体长度的字段。在解析时,先读取内容长度Length,其值为实际消息体内容(Content)占用的字节数,之后必须读取到这么多字节的内容,才认为是一个完整的数据报文。

  header    body
+--------+----------+
| Length |  Content |
+--------+----------+

总的来说,通信协议就是通信双方约定好的数据格式,发送方按照这个数据格式来发送,接受方按照这个格式来解析。因此发送方和接收方要完成的工作不同,发送方要将发送的数据转换成协议规定的格式,称之为编码(encode);接收方需要根据协议的格式,对二进制数据进行解析,称之为解码(decode)。

    Netty中提供大量的工具类,来简化我们的编解码操作,我们将在下一节中进行介绍。


    参考:

    http://blog.csdn.net/robinjwong/article/details/50155115

    http://blog.csdn.net/initphp/article/details/41948919

    http://support.huawei.com/huaweiconnect/enterprise/thread-287745.html

    http://blog.chinaunix.net/uid-29075379-id-3905006.html

    http://jerrypeng.me/2013/08/mythical-40ms-delay-and-tcp-nodelay/

    http://blog.csdn.net/a2796749/article/details/46363713

    http://blog.chinaunix.net/uid-20788636-id-2626119.html?/11207.html

    http://blog.sina.cn/dpool/blog/s/blog_5ec353710101g5a7.html?vt=4

    http://www.2cto.com/net/201310/252965.html

    http://www.cnblogs.com/maowang1991/archive/2013/04/15/3022955.html