TCP 数据传输过程分析

最近在研究发送 / 接收缓冲区对 Socket 性能对影响,朋友发来两篇质量非常高的研究性报告让我看。
Socket 缓存究竟如何影响 TCP 的性能
TCP 性能和发送接收窗口、Buffer 的关系
做研究的,都少不了数据。上面文章中 “缓冲区对 Socket 性能分析” 的数据非常严谨,我非常佩服。
于是我用 WireShark 分析验证 TCP 的数据传输流程,过程包含三次握手、滑动窗口数据传输、四次挥手,收益很多。

头部字段说明

主要分析 TCP 的头字段,有序号、确认序号、ACK、SYN、Option、窗口大小等。详见下图 TCP 头字段说明:

部分内容分析了 IP 的头字段,如包大小等。详见下图 IP 头字段说明:

数据包长度的问题

一个网络包的最大长度,为 65535 字节,为什么是这个大小呢?因为在 IP 网络层,用于标记一个包大小的位数是 16 位,而 16 位能够标记的大小就是 2 的 16 次方,即 65535 字节 (-1)。
上图 IP 头字段中有一个 “总长度(16 位)” 字段,即包大小字段。具体抓包如下:

图中 “Total Length:64” 即当前包大小,通过下部分的绿色 bit 标记显示,可以看出,共有 16 个比特位用来标记 “Total Length” 大小,当前为 64 个字节,即 “00000000 01000000”。
所以,65535 字节大小的包,是 IP 网络层能够从上层接收的最大包大小。
那么 65535 字节的包,是否可以直接发送呢?显然是不行的,因为还有 MTU 的限制。

链路层还有大小限制,为 1500 大小,所以网络层对于大于 1500 字节的包,需要进行分片。
而且,MTU 不是一个定值,一个 1500 字节的包,在网络路由中,可能一个路由仅仅支持 500 大小的包,那么这个包就需要被这个路由分片。
所有分片的包,都需要网络层被重组,然后才能够上传到传输层(如果丢包,传输层会做校验,校验不通过会被丢弃)。而重组,也不一定是服务器完成。比如防火墙需要把包重组后才能做安全防护,所以重组在防火墙这里就可以发生了,当然,服务器的网络传输层也会做重组操作。
以上说明中,提到的字节大小,是包含发送的数据和每一层头字段的总和,所以实际数据会偏小 10-100 字节。

分片,是针对网络层来说的,因为网络层对接链路层,如果发现大于 MTU,就会分片后,交由链路层发送出去。

所以对于 UDP 来说,一个 2000 字节的包,IP 层可能会分成两片后发送出去。
IP 分片后,每个分片后的包单独发送,每个包都有可能丢包。对于 UDP 来说,一个包丢了,整个 UDP 的包都算丢失了。因为对方进行分片重组后,交由 UDP,UDP 会进行数据校验(UDP 的头部有 16 位大小的” 校验和” 字段),发现数据不完整,就会丢弃。所以这个时候这个包就算丢了。

而 TCP 显然不想和 UDP 一样,TCP 已经做分段了,TCP 自己维护了一套数据包的稳定传输,当然不希望 IP 层分片,这样会导致 TCP 的一个包,经过 IP 的分片不稳定传输后,更加不稳定,增加丢包概率。
所以 TCP 就自己维护分段逻辑,在三次握手的时候,确定一个合适的包大小,后续所有的包,都按照这个大小进行传输。
这个大小尽量能直接通过 IP 层而不会被分片(实际上,是否被分片,还由中间路由控制,因为不能绝对不分片),这样,通过滑动窗口,TCP 就很好的控制了包的传输。这里 TCP 确定的一个合适的包大小,就是三次握手的时候确定的。在第一次和第二次握手包里面,双方都会发送自己最大的 MSS,然后双方就有自己和对方的 MSS,取最小值,作为包大小。这个 MSS 存储于第一次和第二次握手包里面的 Option 里面,名为 TCP Option Maximum segment size。详见下图:

图中是抓互联网包中第二次握手的数据,可以看到绿色部分 TCP Option Maximum segment size:1460 bytes。这里第一次握手终端发给服务器的也是 1460 大小,我没有把图截出来。如果双方大小不一致,协商后会按照小的一方来。
这里可以发现,TCP 的包大小,是小于 1500 的。而后面进行正式数据传输的时候,都会以此大小为标准。
但是 TCP 包大小也不是说一定小于 1500,比如下图:

这个图里面,红色框中,Maximum segment size 已经达到 16344 字节,远远大于 1500。
其实这个图是本机服务器的场景,网络包都没有经过网关,是我在本机开了一个 http 服务抓的包。所以在第一次和第二次握手的时候,传输层会考虑当前网络环境,给出一个合适的大小值。
图中可以看到,红色框里面有很多 16388 大小的包传输,这是握手之后正式传输的数据包,表示每个包的大小有 16388 字节。之所以不是 16344,就是上面说到的,网络各层会添加各自的头部,这个也是需要一定空间的,数据包大小是发送的数据和层头部字段的总和,所以会有 10-100 字节的偏差。

三次握手过程

三次握手,首先能想到的就是 SYN 和 ACK。但 SYN 和 ACK 并不是虚无缥缈的东西,它们是实实在在的用头部字段表示的。具体可以看文章顶部 TCP 的头部字段,在中间部分有 SYN 和 ACK 字段,位于 Flag 标志位中,他们分别占据 1 位,是个 bool 值。

ACK 和 SYN

SYN 是用来请求建立连接(建立套接字)的。第一次和第二次握手,在 C 和 S 端均发送了 SYN,表示双方均希望建立连接。
而 ACK 是 SYN 的答复,即” 请求建立连接回复”(同意建立套接字)。第二次和第三次握手,在 S 和 C 端均发送了 ACK,表示双方均回执了对方的建立连接请求。
当三次握手完成后,后面传输的所有数据包,ACK 的位都必须是 1,表示当前套接字已经建立。
详细可以看下图:

其实,三次握手的过程,完成了很多事情,远远不止建立稳定套接字这么简单。下面说几个我分析到的:

序列号

三次握手的时候会确定序列号。每个包传输的时候,都会带一个唯一的序列号。这个序列号在滑动窗口的时候用来做确认标记,当然还有其他用途,比较包超时等。
序列号是一个比较大的数,会以时间戳为依据,每 4ms 会加 1,这样可以防止超时的包最后又正确传输到接收方的过滤操作。
有一个需要注意的是,序列号确认号是相对的。
序列号相对自己的应用层发送的数据包大小递增,只要应用层需要发送数据,那么在传输层每个包的序列号,都是上一个包的序列号加上上一个包的数据大小。如果是回执包,是不会增加序列号的,因为回执包,是 TCP 传输层维护数据完整性用的,都不会上传到应用层,所以不牵涉到应用层的发送数据。对于 TCP 层自己发送的用于维持数据完整性的数据包,不会增加序列号。(三次握手和四次挥手的序列号有些特别,下面会讨论。)
确认号按照接收的数据包大小进行回执,告知对方自己接收了哪些包。因为滑动窗口的缘故,并不会每个数据包都给予确认,而是批量给予确认,所以确认号有可能会跳跃好几个接收的序列号。
序列号详见下图 (序列号是随机的一个比较大的数,在 wireshark 中默认显示相对序号,真实序号为 Sequence number (raw) 字段):

确认号详见下图:

奇怪的握手和挥手序列号

下面重点说一下三次握手和四次挥手的序列号问题。因为这两个阶段序列号比较奇葩,也就是为什么三次握手的时候,第二次的 ACK 要加 1,第三次的 Seq 要加 1。
准确来说,如果一个数据包需要重传,那么这个数据包一定非常重要。所以我们应用层的数据包都是需要重传的。而有些包,或许没有那么重要,我们举例来说,那就是第三次握手。
握手一定需要三次,这是我们都知道的,那么如果第三次握手就是没有发到服务端,难道后面的数据就不能传了吗?
比如说,第一第二次握手都很快完成了,但是第三次握手迟迟没有完成,乃至于一直在超时重发,那么是不是客户端就一定要等第三次消息包确认接收后,才能发送应用层的数据呢?
实际上,并不是。其实客户端在发送第三次握手后,马上就开始发送应用层数据了。如果第三次握手迟迟没有到服务端,但是服务端接收到了客户端发过来的后续数据,那么也认为客户端已经成功接收到了第二次握手的包,所以服务端也同样会建立套接字。
这个也叫做抢跑
所以,对于那些不是非常重要的数据包,这些数据包的序列号是不需要增加的。而序列号增加的最终目的,就是为了做包的整合和过滤。所以细心的朋友看上面图的时候,客户端的回执包的 Seq 一直都是 631,因为回执包如果丢了,还有其他的回执包用于滑动窗口的验证,一个包丢了,问题不大。
第一次握手,客户端的相对序列号为 0,tcp 数据大小(TCP segment length)为 0。按照上面的序列号规则,那么对方的回执号也应该是 0,第三次握手发送的序列号也应该是 0(0+0=0 规则)。
但事实不是这样,服务端的回执是 1,第三次握手发送的序列号也变成了 1。
所以第一次握手,TCP 认为该包一定需要认真对待,如果丢了,一定需要重传,不然握手就没法建立了。所以 TCP 为这个包,默认做了序列号增加的操作。具体应该增加几,TCP 默认增加 1。
详见下图:

第二次握手和第一次握手一样,都非常重要,所以服务端的序列号也做了加 1 的操作,不在说明。
而第三次握手的序列号就没有加 1 了,如下图:

四次挥手也和三次握手一样的逻辑,在第一次和第三次挥手的时候,序列号都做了加 1,而第二次和第四次,就没有做加 1 了。

Window Size(Scale)

在第一次和第二次握手阶段,还确定了一个非常重要的东西,就是窗口大小。我们都知道 TCP 是基于滑动窗口来实现流量控制、顺序传输、丢包重传这三个重要机制的。但很多人不知道,窗口的大小,其实在握手阶段就已经最初确定。这里说最初两个字,是因为窗口大小在数据传输过程中,还会变化。

握手中的窗口大小

在第一和第二次握手的过程中,双方会互相发送 window size 头字段,表示窗口大小。最终会取最小值参与窗口大小的计算。
之所以说参与窗口大小的计算,而不是确定,是因为第一第二次握手确定的窗口大小,并不是最终大小。还有一个 scale 字段,最终窗口大小的值是 scale 和 window size 相乘的值,即 scale * window size。
scale 字段,在第一次和第二次握手的时候,是在 Option 字段里面存储的,占据 3 个字节大小,存储的是位移运算的偏移。如果 scale 值为 6,则为 64,即 1 进行左移 6 位,如果值为 7,则为 128。
这个 scale 说来还有一个特别,在握手阶段确定之后,就不会改变了。
在第一次和第二次握手里面,scale 已经协商并确定,但是最终窗口大小的值确是由第三次握手确认的。说起来有点绕,我举例说一下。

首先说一下为什么需要 scale。在文章首部,TCP 头字段里面,有一个窗口大小的标记位,共 16 位。也就是说,TCP 默认支持的滑动窗口大小,最大为 2 的 16 次方,即 65535 字节。
但是随着互联网的快速发展,网络越来越好,带宽越来越大,65535 字节的窗口大小,已经不能满足客观的互联网需求,就是说窗口太小了。
所以这就弄出来一个 scale,用 TCP 头字段的 window size * (1<<scale),来标记最终窗口大小。

这里举个例子,如果客户端 Window size = 1000,scale = 6,服务器 Window size = 2000,scale = 7,那么最终窗口大小就是 1000 * (1<<6),即 64000 字节大小。

但是有时候呢,客户端和服务器的资源可能都非常好,比较内网或者本机环境,这个时候客户端 Window size = 65535(满了),scale = 6, 服务器 Window size = 65535(也满了),scale = 6
这个时候,如果计算滑动窗口,65535*(1<<6)=4194240字节=4M。这个时候滑动窗口又太大了,所以这个时候就需要在第三次握手的时候,重新计算 Window size。第三次握手的时候,TCP 的头部 Window size 可能会变成 6739,这个时候窗口大小为 6739*(1<<6)=408256字节

具体流程详见下图:

上面说到,scale 在第一次和第二次握手的时候,就已经确定,并且后面不会更改。我在抓包的时候,的确没有在其他数据包里面发现 scale 字段。
TCP 毕竟只是传输层协议,它不管数据是啥,只管传输。那么后续如何根据 scale 来计算窗口大小的呢?
我猜测是把这个字段放到套接字里面了。原因有两个,一:我的确没有找到其他数据包里面有 scale 标记,二:scale 是因为互联网快速发展起来后才加上的,这个时候 TCP 头字段已经确定,并且写死到计算机内核中无法修改。所以只能放在其他位置,比如套接字。

传输过程中的窗口大小调整

因为滑动窗口的机制,如果发送方发送的数据,接收方能够及时消化掉,那么滑动窗口就保持不变。
如果接收方来不及处理发送方发过来的数据,会导致接收缓冲区满,这个时候发送方继续发送数据,接收方也无法读取了。
所以发送方就选择暂时不发送数据。发送方如何知道接收方来不及处理这么一个状态呢?那就需要接收方通过滑动窗口告知到它了。
所以发送和接收方,在数据传输的过程中,会及时的将双方的窗口状态发送到对方。这里对方指的是客户端或者服务端。因为客户端和服务端都可能成为发送方或者接收方。
在数据传输这个过程中,发送和接收方的窗口,是不一样大的。因为这是客户端和服务端两个端的状态,发送方发出的数据,如果接收方一直没有响应,那么发送方的数据只能继续呆在发送缓冲区,这个时候发送方的窗口可能还比较大,而接收方因为无法处理更多数据,可能窗口都已经关闭了。

具体抓包如下:

传输过程中,窗口变化情况很多,上面也只举例说了一部分。

四次挥手

四次挥手,抓包上看过程和三次挥手很相似。主要有下面几点需要注意:

  1. 双方都可以发送断开连接请求。即 C 和 S 均可以主动发起第一次挥手。
  2. FIN 断开连接(销毁套接字)。第一次和第三次挥手,会发送这个 FIN。
  3. ACK 断开连接回复(同意断开连接)。第二次、第三次、第四次挥手,会发送这个 ACK。
  4. 第三次挥手后,消息接收方会发送第四次握手,并立刻处于 TIME_WAIT(等待)状态。这个状态会等待 2MSL 时间后,才断开连接。这里需要等待的时间足够长(2MSL),因为第四次挥手可能会丢包,所以需要重发,所以不能立刻断开。如果立刻断开,另一端收不到第四次挥手,可能认为对方还不想断开连接,那么套接字就会一直存在,消耗资源。还有,MSL 是包在网络上的最长时间,超过这个时间,包就认为需要被丢弃,会回执 ICMP 的错误回执包。MSL 的值不等,一般为 30s/60s/120s。
  5. 还有一点,上面说到一端收不到第四次挥手,可能会一直保留套接字,导致消耗资源。其实当它发出第三次挥手,并迟迟等不到第四次握手,相当于它认为它发出的第三次挥手包丢了,所以会重发第三次挥手包。这个时候,如果另一端已经过了 2MSL,会自动销毁,然后重发的第三次挥手包,会没有接收方,ICMP 会告知错误,这个时候它也可以判断另一端已经走了,自己可以销毁资源了。

抓包如下:

TCP Keep-Alive

TCP 会自行保持 TCP 通道的稳定性,这个和 HTTP 的 keep-alive 不一样。TCP 的 Keep-Alive 是纯通道层的心跳,用于验证当前双方的连接(套接字)是否稳定。
具体发送时机和失败后的发送次数和间隔,操作系统有默认参数,也可以手动调整。默认为 7200s 后发送第一个检测包,之后每隔 75s 发送一次。如果没有收到回执包,则连续重试 9 次,每次重试时间翻倍。
具体发送信息为:[TCP Keep-Alive] 和 [TCP Keep-Alive ACK]
详见下图:

滑动窗口

上面在介绍窗口大小的时候,已经说明过滑动窗口的抓包数据分析。
具体来说,在数据传输过程中,应用层可能不会立刻读取缓冲区数据,所以有一部分已经接收的数据依旧存储在缓冲区中,但他们还没有被读取,而且它们占据了一部分缓冲区大小。这个时候窗口大小就需要调整了,因为发送方如果继续按照之前的窗口约定一直发送数据,缓冲区已经不能接收更多数据了,所以这些包都会被丢弃,而发送方一直会收不到包接收成功的回执包。所以通信双方一定要实时约定窗口大小。
滑动窗口,主要是为了不把接收方的缓冲区塞满,这样就实现了流量控制。也同时实现了顺序整理,丢包重传策略。
在滑动窗口大小标记为 0 后,双方会间隔性发送探测包,询问当前是否已经有合适的窗口继续发送数据。如果拥堵方缓冲区数据被读取了,这个时候窗口大小充足后,会立刻发送 [TCP Window Update] 消息包,告知另一端,可以继续发送数据了。

丢包重传优化

在上面说 [TCP Window Full] 的时候,我们遇到了一个重传包的情况,即 [TCP Retransmission]。当时接收方缓冲区非常紧张,导致不能及时的消化发送方的数据,所以 170ms 内没有给发送方发送回执包。发送方以为包丢了,所以重传了。
每一个包,如果在规定时间里面没有收到回执包,即认为丢包。丢包后,会根据指数级的延时进行重发。这里就会有一个问题,如果一个包在前几次都发送失败了,后面会效率很低,因为要很久才能够重发。在滑动窗口的时候,会有一个冗余回执的优化,即按照 1-9 这 9 个包排序,其中 5 包丢失了,后面接收方在收到 7、8、9 包后,会发送 4 包的回执,表示 5 包没有收到。这样发送方在收到 3 次 5 包的冗余回执后,会立刻进入快速重传,避免了超时周期过长的问题。


太累了,累哭了,写技术文章太累了。
写字加画图,喝了两大杯咖啡和一罐红牛,真费钱。
远远没有牛逼好吹。
如果有人说牛逼难吹,那一定是书看少了知识不够渊博。
我就很佩服牛逼吹的又响又脱俗的人。