【网络协议】万文长篇,带你深入理解 TCP;场景复现,掌握鲜为人知的细节(上)

21,173 阅读17分钟

本文已参与「新人创作礼」活动,一起开启掘金创作之路。

前言

由于内容细致,导致篇幅过长,因此将分为三部分来讲述,目录如下:

【网络协议】万文长篇,带你深入理解 TCP;场景复现,掌握鲜为人知的细节(上)

  • 概述;
  • 状态机;
  • 三握四挥;
  • 粘包拆包;(TCP 是基于流的,其实没这表述的)

【网络协议】万文长篇,带你深入理解 TCP;场景复现,掌握鲜为人知的细节(中)

  • 重传机制;
  • 滑动窗口;
  • 流量控制;
  • 拥塞控制;

【网络协议】万文长篇,带你深入理解 TCP;场景复现,掌握鲜为人知的细节(下)

  • 握手失败;
  • 挥手失败;
  • 为什么是三次握手?
  • 如何避免 SYN 攻击?
  • MTU 与 MSS 那些事儿;
  • TIME_WAIT 的巧妙设计;
  • 初始序列号 ISN 为什么不同?
  • 知道 TCP 的最大连接数吗?

概述

传输控制协议(TCP,Transmission Control Protocol) 是一种面向连接的、可靠的、基于字节流的传输层通信协议,由 IETF 的 RFC 793 定义;

TCP 旨在适应支持多网络应用的分层协议层次结构。 连接到不同但互连的计算机通信网络的主计算机中的成对进程之间依靠 TCP 提供可靠的通信服务。TCP 假设它可以从较低级别的协议获得简单的,可能不可靠的数据报服务。 原则上,TCP 应该能够在从硬线连接到分组交换或电路交换网络的各种通信系统之上操作。

TCP 是一种面向广域网的通信协议,目的是在跨越多个网络通信时,为两个通信端点之间提供一条具有下列特点的通信方式:

(1)基于流的方式;

(2)面向连接;

(3)可靠通信方式;

(4)在网络状况不佳的时候尽量降低系统由于重传带来的带宽开销;

(5)通信连接维护是面向通信的两个端点的,而不考虑中间网段和节点。

为满足 TCP 协议的这些特点,TCP 协议做了如下的规定:

① 数据分片:在发送端对用户数据进行分片,在接收端进行重组,由 TCP 确定分片的大小并控制分片和重组;

② 到达确认:接收端接收到分片数据时,根据分片数据序号向发送端发送一个确认;

③ 超时重发:发送方在发送分片时启动超时定时器,如果在定时器超时之后没有收到相应的确认,重发分片;

④ 滑动窗口:TCP 连接每一方的接收缓冲空间大小都固定,接收端只允许另一端发送接收端缓冲区所能接纳的数据,TCP 在滑动窗口的基础上提供流量控制,防止较快主机致使较慢主机的缓冲区溢出;

⑤ 失序处理:作为 IP 数据报来传输的 TCP 分片到达时可能会失序,TCP 将对收到的数据进行重新排序,将收到的数据以正确的顺序交给应用层;

⑥ 重复处理:作为 IP 数据报来传输的 TCP 分片会发生重复,TCP 的接收端必须丢弃重复的数据;

⑦ 数据校验:TCP 将保持它首部和数据的检验和,这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到分片的检验和有差错,TCP 将丢弃这个分片,并不确认收到此报文段导致对端超时并重发。

 

基于流的方式

TCP 是一种字节流(byte-stream)协议,流的含义是没有固定的报文边界。

当用户消息通过 TCP 协议传输时,一条消息可能会被操作系统分组成多个的 TCP 报文,也就是一个完整的用户消息被拆分成多个 TCP 报文进行传输,也有可能将多条消息组成一个 TCP 报文进行传输。

这是因为在发送端,当我们调用 send 函数完成数据 发送 以后,数据并没有真正从网络上发送出去,而是从应用程序拷贝到了操作系统内核协议栈中,至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。也就是说,我们不能认为每次 send 调用发送的数据,都会作为一个整体完整地消息被发送出去。

这时,接收方的程序如果不知道发送方发送的消息的长度,也就是不知道消息的边界时,是无法读出一个有效的用户消息的;

举个例子,比如发送端陆续调用 send 函数先后发送消息 「Hello World,」「sid10t.」, 那么考虑实际网络传输过程中的各种影响,在实际发送过程中可能会出现以下几种情况,不考虑两个报文的实际大小

  • 情况一: 正常发送,两个报文大小均小于协商的 MSS,且又符合真正发送条件;

  • 情况二: 合并发送,TCP 将两个数据包打包成一个 TCP 报文发送出去;

  • 情况三: 前拆分,后合并;

  • 情况四: 前合并,后拆分;

 

面向连接

 

连接与断开

  • 连接:接收端 在自己的监听端口接收到连接请求,三次握手 之后,维护一定的数据结构和 发送端 的信息,如果确认了该信息:接收端 发送的内容会被 发送端 接收, 发送端 发送的内容也会被 接收端 接收,直至连接断开。
  • 断开:通过 四次挥手 确保双方都知道且都同意对方断开连接,然后 remove 为对方维护的数据结构和信息,对方之后发送的数据包也不会被接收,直到再次建立连接。  
本质是数据结构

TCP 建立连接的本质是在客户端和服务端各自维护一定的数据结构(一种状态机),来记录和维护这个 连接 的状态,并不是真的在这两个端之间有一条类似 “网络专线” 的东西。

在 IP 层,网络情况该不稳定还是不稳定,数据传输走的什么路径不是上层所能控制的,TCP 能优化的就只有做更多判断,重试,拥塞控制之类的东西。  

连接只是术语

数据包最终都是通过链路层、物理层等一层一层出去的,所以连接只是一种逻辑术语,并不存在像管道那样子的东西,连接 在这里相当于双方的一种约定,双方按协商的规矩维护状态。  

维护状态

三次握手之后,客户端与服务端之间能够确认自己发送的数据能被对方所接收,因此只需要维护这种状态就可以了,连接就此建立;

连接是一种状态,建立连接是维持一种状态,维持状态通过一定的数据结构来完成;  

口诀

我心里有你,并不是我心里真的有你(连接并不是像管子一样真的连着),而是一种感觉,我每天想着你,你占据着我内心的一份空间(数据结构),是一种状态,我需要一直维护,天天想你(状态机)。你生气的时候,我殷勤一点,你高兴的时候,我放松一点(拥塞控制,快慢有度),直到我遇到了另一个她,再见(删除数据结构),唉,男人!

 

可靠通信方式

IP 是一种无连接、不可靠的协议:它尽最大可能将数据报从发送者传输给接收者,但并不保证包到达的顺序会与它们被传输的顺序一致,也不保证包是否重复,甚至都不保证包是否会达到接收者。不保证有序、去重、完整。

TCP 要想在 IP 基础上构建可靠的传输层协议,必须有一个复杂的机制来保障可靠性。 主要有下面几个方面,在上方概述中都有所提及:

  • 数据分片;
  • 到达确认;
  • 超时重发;
  • 滑动窗口;
  • 失序处理;
  • 重复处理;
  • 数据校验;
  • 拥塞控制;

 

采用全双工协议

全双工通信又称为双向同时通信,即通信的双方可以同时发送和接收信息的信息交互方式。 RS-422 标准就是全双工通信标准。 全双工(Full Duplex)是在微处理器与外围设备之间采用发送线和接受线各自独立的方法,可以使数据在两个方向上同时进行传送操作。

在 TCP 中发送端和接收端可以是客户端/服务端,也可以是服务器/客户端,通信的双方在任意时刻既可以接收数据也可以发送数据,每个方向的数据流都独立管理序列号、滑动窗口大小、MSS 等信息。

 

报文首部

  • 序列号(Sequence Number):在建立连接时由计算机生成的随机数作为其初始值,通过 SYN 包传给接收端主机,每发送一次数据,就「累加」一次该「数据字节数」的大小。用来解决网络包乱序问题。

  • 确认应答号(Acknowledgement Number):指下一次「期望」收到的数据的序列号,发送端收到这个确认应答以后可以认为在这个序号以前的数据都已经被正常接收。用来解决不丢包的问题。

  • 控制位:

    • ACK:该位为 1 时,「确认应答」的字段变为有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必须设置为 1 。
    • RST:该位为 1 时,表示 TCP 连接中出现异常必须强制断开连接。
    • SYN:该位为 1 时,表示希望建立连接,并在其「序列号」的字段进行序列号初始值的设定。
    • FIN:该位为 1 时,表示今后不会再有数据发送,希望断开连接。当通信结束希望断开连接时,通信双方的主机之间就可以相互交换 FIN 位为 1 的 TCP 段。
    • URG紧急标志位,表示的是此报文段中有紧急数据,将紧急数据排在普通数据的前面;当接受端收到此报文后后必须先处理紧急数据,而后再处理普通数据。
    • PSH催促标志位,当发送端将 PSH 置为1时,TCP会立即创建一个报文并发送。接受端收到 PSH 为1的报文后就立即将接受缓冲区内数据向上交付给应用程序,而不是等待缓冲区满后再交付。
  • 窗口大小:用于 TCP 流量控制。告诉对方本端的 TCP 接收缓冲区还能容纳多少字节的数据,这样对方就可以控制发送数据的速度。

  • 校验和:由发送端填充,接收端对 TCP 报文段执行 CRC 算法以检验 TCP 报文段在传输过程中是否损坏,检验的范围包括头部、数据两部分,是 TCP 可靠传输的一个重要保障。

  • 紧急指针:一个正的偏移量。它和序列号字段的值相加表示最后一个紧急数据的下一个字节的序列号,用于发送端向接收端发送紧急数据。

 

状态机

状态描述
CLOSED关闭状态,没有连接活动或正在进行;
LISTEN监听状态,服务器正在等待连接进入;
SYN_SENT已经发出连接请求,等待确认;
SYN_REVD收到一个连接请求,尚未确认;
ESTABLISHED连接建立,正常数据传输状态;
FIN_WAIT_1(主动关闭)发送关闭请求,等待对方关闭确认;
FIN_WAIT_2(主动关闭)收到对方关闭确认,等待关闭请求;
CLOSE_WAIT(被动关闭)收到对方关闭请求,发送确认请求;
LAST_ACK(被动关闭)等待最后一个关闭确认,并等待所有分组死掉;
TIME_WAIT完成双向关闭,等待所有分组死掉;
CLOSING双方同时尝试关闭,等待对方确认;

 

三握四挥

在对 TCP 有一定的了解之后,那我们就进入正题,TCP 是面向连接的,那么 TCP 是如何建立连接的呢,这也是面试热点问题,TCP 的三次握手与四次挥手,不过在正式介绍三握四挥之前,先在 Linux 上安装网络协议栈测试神器 packetdrill,方便后续的实验操作;  

packetdrill

packetdrill 是 Google 开源的一个 测试脚本工具,可以用于测试 TCP、UDP、IP 网络协议栈,其是由基于时间序的脚本行组成,按时间顺序逐条执行。

它的语言设计十分接近于 tcpdump 和 strace ,包含四种类型的语句:

  • 数据包。使用类似于 tcpdump 的语法,支持 TCP、UDP、ICMP 数据包,同时也提供了常见 TCP 选项的配置,包括 SACK、MSS、window scale 等。
  • 系统调用。使用类似于 strace 的语法。
  • Shell 命令。通过``进行调用,可以进行系统参数配置或断言验证网络协议栈状态。
  • Python 脚本。通过 %{ command }% 进行调用,可以输出或者断言验证 TCP 状态。

关于 packetdrill 的安装配置博主是参考这篇文章的 packetdrill 工具安装,官方 github 地址:google/packetdrill

安装完成之后,可以测试一下 tests/linux/fast_retransmit/ 目录下的 fr-4pkt-sack-linux.pkt,这是官方提供的测试脚本:

./packetdrill tests/linux/fast_retransmit/fr-4pkt-sack-linux.pkt

注意:在自己写脚本时,需要确保行尾序列LF,不然就等着怀疑人生吧,谁用谁知道;一模一样复制过去的代码,因为行尾序列不一样,一直在报错:

如果什么回显都没有就表示成功了,不然按照报错进行更改;

因为 socket 绑定的端口默认是 8080,因此可以通过 tcpdump 进行监听,tcpdump -t -i any port 8080,如果你知道自己的网卡,比如是 eth0,那就把 any 换成 eth0

如果你觉得 tcpdump 用的不太适应,也可以将数据映射到 Wireshark 中去,在 Wireshark 文件夹下打开 cmd 并使用下列指令,

ssh root@host -p port "tcpdump -i any -n tcp port 8080 -s 0 -l -w -" | "Wireshark.exe" -k -i -

那接下来就让我们探究一下 TCP 是如何建立连接的吧;  

三次握手

 

握手之前:客户端和服务端都处于 CLOSED 状态。先是服务端主动监听某个端口,处于 LISTEN 状态;

 

第一次握手

客户端会随机初始化序号(client_isn),将此序号置于 TCP 首部的「序列号」字段中,同时把 SYN 标志位置为 1 ,表示 SYN 报文。接着把第一个 SYN 报文发送给服务端,表示向服务端发起连接,该报文不包含应用层数据,之后客户端处于 SYN-SENT 状态。

第一个报文—— SYN 报文如下图所示:

 

第二次握手

服务端收到客户端的 SYN 报文后,首先服务端也随机初始化自己的序号(server_isn),将此序号填入 TCP 首部的「序列号」字段中,其次把 TCP 首部的「确认应答号」字段填入 client_isn + 1, 接着把 SYN 和 ACK 标志位置为 1。最后把该报文发给客户端,该报文也不包含应用层数据,之后服务端处于 SYN-RCVD 状态。

第二个报文 —— SYN + ACK 报文如下图所示:

 

第三次握手

客户端收到服务端报文后,还要向服务端回应最后一个应答报文,首先该应答报文 TCP 首部 ACK 标志位置为 1 ,其次「确认应答号」字段填入 server_isn + 1 ,最后把报文发送给服务端,这次报文可以携带客户到服务器的数据,之后客户端处于 ESTABLISHED 状态。

第三个报文 —— ACK 报文如下图所示:

  握手之后:服务器收到客户端的应答报文后,也进入 ESTABLISHED 状态;  

从上面的过程可以发现第三次握手是可以携带数据的(前两次握手是不可以携带数据),服务器必须等收到 ACK 分组之后才能发送数据,这也是面试常问的题。

一旦完成三次握手,双方都处于 ESTABLISHED 状态,此时连接就已建立完成,客户端和服务端就可以相互发送数据了。

 

四次挥手

天下没有不散的宴席,对于 TCP 连接也是这样, TCP 断开连接是通过四次挥手方式。

双方都可以主动断开连接,断开连接后主机中的「资源」将被释放。

 

第一次挥手

客户端打算关闭连接,此时会发送一个 TCP 首部 FIN 标志位被置为 1 的报文,也即 FIN 报文,之后客户端进入 FIN_WAIT_1 状态。

 

第二次挥手

服务端收到该报文后,就向客户端发送 ACK 应答报文,接着服务端进入 CLOSED_WAIT 状态。

客户端收到服务端的 ACK 应答报文后,之后进入 FIN_WAIT_2 状态。

 

第三次挥手

等待服务端处理完数据后,也向客户端发送 FIN 报文,之后服务端进入 LAST_ACK 状态。

 

第四次挥手

客户端收到服务端的 FIN 报文后,回一个 ACK 应答报文,之后进入 TIME_WAIT 状态。

服务器收到了 ACK 应答报文后,就进入了 CLOSED 状态,至此服务端已经完成连接的关闭。

客户端在经过 2MSL 一段时间后,自动进入 CLOSED 状态,至此客户端也完成连接的关闭。

 

每个方向都需要一个 FIN 和一个 ACK,因此通常被称为 四次挥手,不过需要注意的是,主动关闭连接的,才有 TIME_WAIT 状态

 

场景复现

在 Linux 可以通过 netstat -napt 命令查看 TCP 的连接状态:

根据上述的理论,利用 packetdrill 自行构建环境,下面博主只是构造了三次握手和四次挥手的过程,并没有中间的数据传输过程:

0   socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
+0  setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
+0  bind(3, ..., ...) = 0
+0  listen(3, 1) = 0
+0 `echo socket is listening!`

+0  < S 0:0(0) win 4000 <mss 100>
+0  > S. 0:0(0) ack 1 <...>
+.1 < . 1:1(0) ack 1 win 1000
+0  accept(3, ..., ...) = 4
+0  `echo connection established!`

+0 < F. 1:1(0) ack 1 win 1000
+0 > . 1:1(0) ack 2
+0.1 close(4)=0
+0 > F. 1:1(0) ack 2 <...>
+0.01 < . 2:2(0) ack 2 win 1000
+0 `echo connection closed!`

+0 `sleep 100`

 

思考

现实中的网络环境并不是这么一帆风顺的,总会有各种的意外情况发生,毕竟现实是骨感的;

例如在第二次握手时,服务端回复的 ACK 报文丢失了,那应该怎么处理呢?是客户端重发 SYN 报文还是服务端重发 ACK 报文呢?以及其他的各种问题;

预知后事如何,请客官移步 【网络协议】万文长篇,带你深入理解 TCP;场景复现,掌握鲜为人知的细节(下)

 

粘包拆包

TCP 是基于流的,其实没这表述的,这里就只是解释一下,看到有很多说 TCP 粘包头头是道的,懂得都懂;

TCP 其实就是背锅侠,多个数据包粘连到一起无法拆分是我们的需求过于复杂造成的,是程序猿的问题而不是协议的问题,TCP 协议表示这锅它不想背。

服务器端如果想保证每次都能接收到客户端发送过来的这个不定长度的数据包,程序猿应该如何解决这个问题呢?下面提供几种解决方案:

  1. 使用标准的应用层协议(比如:http、https)来封装要传输的不定长的数据包;
  2. 在每条数据的尾部添加特殊字符,如果遇到特殊字符,代表当条数据接收完毕了;
    • 有缺陷:效率低,需要一个字节一个字节接收,接收一个字节判断一次,判断是不是那个特殊字符串;
  3. 在发送数据块之前,在数据块最前边添加一个固定大小的数据头,这时候数据由两部分组成:数据头 + 数据块:
    • 数据头:存储当前数据包的总字节数,接收端先接收数据头,然后在根据数据头接收对应大小的字节;
    • 数据块:当前数据包的内容;

 

后记

欢迎各位大佬指正,在评论区多多讨论;

文中脚本代码戳这里...

站在巨人的肩膀上看 TCP,感谢参考: