三次握手与四次挥手


十二月的第四周,来完整学习 TCP 的三次握手和四次挥手。

本来学习完 Redis 后想先学 MySQL 的,但 MySQL 的学习曲线比想象的陡。工作时用到了 Wireshark,配合掘金小册《深入理解 TCP 协议:从原理到实战》重学了一遍 TCP/IP 协议(这本书写得是真的好),这周先学习 TCP 建立连接与释放连接。

本文的配图基本都来源自《深入理解 TCP 协议:从原理到实战》。


抓包工具 Wireshark

首先介绍一下 Wireshark,这是一个开源的网络数据包分析软件(抓包工具)。

工作中需要抓包分析,使用了 Fiddler 和 Wireshark 两款软件。

  • Fiddler 是一个用于 HTTP 调试的抓包工具,它能捕获 HTTP 和 HTTPS 请求。

    Fiddler 的工作原理是代理。Fiddler 在启动时会自动设置好代理地址,客户端发送 HTTP 请求时,会首先发送给代理(Fiddler),再由代理转发送达服务端,反之也是如此。

  • Wireshark 是一个用于抓取一切网络数据包的工具,它抓取的是 0101 的二进制比特流,并基于七层网络协议解析成 IP、TCP、UDP、HTTP 等各种网络协议。由于开源,众多代码贡献者基本让 Wireshark 支持所有的网络协议。

    Wireshark 的工作原理是绑定网关,所有网络请求都要流经网关,也就都被 Wireshark 捕获到。

我们今天只学习功能更强大的 Wireshark。

Wireshark 的界面如下图所示:

Wireshark界面

上半部分是所有网络数据包(包含时间、发送方 IP 地址、接收方 IP 地址、网络协议),下半部分是单个网络数据包的具体内容(点击哪个就展示哪个),它按照七层网络协议展示。

我们今天基本只会看运输层(TCP 所在的那一层),下图是 Wireshark 中查看网络层和运输层的界面。

wireshark网络层和运输层

网络层提供 IP 地址,运输层提供端口号,源 IP、源端口、目标 IP、目标端口构成了 TCP 连接的「四元组」。


我们以访问百度为例,进行一次抓包:

1
curl -v www.baidu.com

该访问的抓包文件可以在 github 下载:curl_baidu.pcapng


首先把 TCP 报文首部示意图贴出来,这是 TCP 协议的基石,我们接下来会反复看它。

TCP头部

三次握手

三次握手在 Wireshark 中是这样的:

三次握手

本地(192.168.31.240)向百度服务器(14.215.177.38)发送建立 TCP 连接的请求,百度服务器回复本地,本地再回复百度服务器,这三次请求被称为三次握手。


以第一次握手为例,讲解 TCP 报文包含什么信息:

TCP第一次握手

源端口号(Source Port)、目标端口号(Destination Port)

源端口号是 61024,这就是我们本机的端口号

目标端口号是 80,这是百度服务器的端口号(HTTP 的默认端口号)

每个端口号占 2 字节(16 bit),因此端口号最大也只有 65535,熟知的端口号有:

  • 80:HTTP
  • 443:HTTPS
  • 22:SSH

序列号(Sequence Number)

序列号是一个 32 位的无符号整数,达到 2^32 - 1 后循环到 0。

序列号是用来排序的,排发送 TCP 包的顺序。因为网络层(IP 层)不保证包的顺序,因此发送方依次发送 1、2、3、4 四个 TCP 包,接收方接到时可能是 2、4、3、1。为了解决网络包乱序、重复的问题,TCP 头部引入序列号的概念。

序列号是按照报文段的长度累加的(不算头部的长度),比如发送方发送某个 TCP 包的序列号是 10,该包的报文长度是 78,那么发送方下一次发送时,序列号就是 10 + 78 = 88。

TCP序列号

三次握手的首次握手,初始的序列号(ISN)不是从 0 开始的,也不是某个固定的数字,而是随时间而变化。将 ISN 设置成固定值会有两个问题:

  1. 安全问题,如果知道了连接的 ISN,很容易伪造一个 RST 包,将连接强制关闭。如果采用动态 ISN,伪造一个在对方窗口内的序列号就会相对困难。
  2. 开启 SO_REUSEADDR 以后端口允许重用,这样收到一个包,并不知道是新连接还是因为网络原因姗姗来迟的旧连接。

三次握手发送序列号的过程如下图所示(第二次握手拆成两步)。

TCP序列号发送

还有一个有关序列号的细节,那就是 SYN 报文(SYN 是 TCP flags,在后文有写)不携带数据,但是它占用一个序号,下次发送数据序列号要加一,但是 ACK 报文不会占用序列号。这个细节可以从这个角度来理解:消耗序列号的 TCP 报文段,一定需要对方确认,SYN 报文需要 ACK 报文确认,但是 ACK 报文并不需要。如果消耗序列号的 TCP 报文段没有收到确认,会一直重传(有重试次数上限)。

确认号(Acknowledgment Number)

TCP 使用确认号(Acknowledgment number, ACK)来告知对方下一个期望接收的序列号,小于此确认号的所有字节都已经收到。

TCP确认号

不是所有包都需要确认,例如 ACK 包就不需要(不然就死循环了)。

TCP Flags

TCP 有很多标记,比如三次握手就使用了 SYN 和 ACK 标记。

TCP Flags 是一个 8 bit 的 bitmap,每位代表一个状态,0 代表关闭,1 代表开启。

如图是第一次握手时,TCP Flags 部分的示意图,只有 SYN 标记置 1,其他标记都是 0。

TCP Flags

TCP 三次握手使用到了两个标记,SYN 和 ACK。SYN(Synchronize Sequence Numbers)代表同步序列号的意思,也就是要发起连接了;ACK(Acknowledge)代表确认。

常用的 TCP 标记的意义如下:

  • SYN(Synchronize):用于发起连接数据包同步双方的初始序列号
  • ACK(Acknowledge):确认数据包
  • RST(Reset):这个标记用来强制断开连接,通常是之前建立的连接已经不在了、包不合法、或者实在无能为力处理
  • FIN(Finish):通知对方我发完了所有数据,准备断开连接,后面我不会再发数据包给你了。
  • PSH(Push):告知对方这些数据包收到以后应该马上交给上层应用,不能缓存起来

窗口大小(Window Size)

TCP 使用滑动窗口的概念进行流量控制,如果发送端发送 TCP 包很快,接收端处理不过来,接收端就会先将 TCP 包缓存起来。接收方发送 ACK 响应时,会告诉发送方下一次自己能接收多少数据,这个值就是窗口大小

窗口大小占 16 bit,最大能代表 65535 个字节(64 KB),这个值在 TCP 协议创建时很大,但如今就显得很小了。为了解决这个问题,TCP 引入了窗口缩放的概念,最多能够放大 2^14 倍。如下图所示,缩放因子为 7,则真正的窗口大小为 1050 * 128 = 134400。

TCP窗口缩放

窗口缩放因子在三次握手时指定,在 TCP 头部信息的可选项中指定(可选项下面就会说)。

如果 Wireshark 没有抓到首次握手,也就不知道窗口缩放因子,是不知道真正的窗口缩放值是多少的。

可选项

TCP可选项

可选项的格式如下图所示:

TCP可选项格式

以窗口缩放因子 Window scale 为例,kind=3,length=3,value=6

TCP可选项示例

常用的选项有以下几个:

  • MSS:最大段大小选项,是 TCP 允许的从对方接收的最大报文段
  • SACK:选择确认选项
  • Window Scale:窗口缩放选项

至此我们看完了 TCP 头部字段,接下来快速理顺一遍三次握手。

三次握手

第一次握手:客户端给服务端发一个 TCP 报文,将 TCP Flags 中 SYN 标志置 1,并指明客户端的初始化序列号 ISN。

第二次握手:服务端给客户端发一个 TCP 报文,将 TCP Flags 中 SYN 标志置 1,指定服务端的初始化序列号 ISN,并将 TCP Flags 中 ACK 标志也置 1,确认序列号设置成第一次握手传来的客户端 ISN + 1,表示接收到第一次握手。

第三次握手:客户端给服务端发一个 TCP 报文,将 TCP Flags 中 ACK 标志置 1,确认序列号设置成第二次握手传来的服务端 ISN + 1,表示接收到第二次握手。

有一篇拓展阅读:《关于三次握手和四次挥手,面试官想听到怎样的回答?》。


四次挥手

TCP 建立连接需要三次,但是断开连接需要四次,这是 TCP 的半关闭造成的。半关闭是指,不能发送,但还可以接收,保证自己不再发送 TCP 包,但是不确定对方是否也是,因此还可以一直接收对方的 TCP 包。

鉴于三次握手已经把 TCP 头部信息说明得比较清楚了,这里写四次挥手就简单过一下。

四次挥手

第一次挥手:客户端给服务端发送一个 TCP 包,指定 TCP Flags 中的 FIN 标志为 1,该包消耗一个序列号。在此之后,客户端将不能给服务端发送 TCP 包(FIN 包重发不算)。

第二次挥手:服务端给客户端发送一个 TCP 包,指定 TCP Flags 中的 ACK 标志为 1,确认号是接收第一次挥手的 FIN 包的序列号 + 1。

服务端继续发送还没发送完的 TCP 包,发送完之后再第三次挥手。

第三次挥手:服务端给客户端发送一个 TCP 包,指定 TCP Flags 中的 FIN 标志为 1,该包消耗一个序列号。在此之后,服务端将不能给客户端发送 TCP 包(FIN 包重发不算)。

第四次挥手:客户端给服务端发送一个 TCP 包,指定 TCP Flags 中的 ACK 标志为 1,确认号是接收第三次挥手的 FIN 包的序列号 + 1。客户端等待 2 个 MSL 后关闭。


接下来回答几个细节问题:

四次而不是三次

为什么一定需要四次握手,三次不行吗?

如果把四次缩减成三次,那就是把第二次挥手和第三次挥手合并(第一次、第四次是不能砍掉的),也就是服务端得知客户端要断开连接时,发送一个 TCP 包,同时携带 ACK(确认收到第一次挥手)和 FIN(告知服务端也要关闭了)信息。

但是服务端不一定立即就能发送 FIN 包,因为服务端可能还有 TCP 包没发送完。服务端要一直等到自己发送完了所有的 TCP 包,才能发送 FIN 包,如果把第二次和第三次挥手合并,就会导致 ACK(确认收到第一次挥手)可能发送得特别迟。从客户端角度观察就是,我这边发送了 FIN 包申请断开连接,结果迟迟没有收到回复,那只好不停地重发。

因此把四次挥手缩减成三次也不是不行,但是会有不停重发的代价,没有必要。

顺便提一下,建立连接时只需要三次握手,是因为服务端刚刚收到 SYN 包时,并没有任何还没发送的信息需要发送,因此不需要先确认收到,再申请建立连接。

FIN 也需要占用一个序列号

除了 ACK 之外,其他 TCP 包都需要占用序列号。

对于 FIN 而言,这是因为 FIN 也是需要确认的。如果 FIN 不占用序列号,客户端先发送了一个 TCP 包,再发送一个申请关闭连接的 FIN 包,那么接到 ACK 回复时,客户端并不知道这是确认哪一个 TCP 包的,有可能是确认最后发送消息的那个 TCP 包,也可能是确认 FIN 包的。

需要得到回复的 TCP 包,都需要占用序列号。

标记 FIN 时其实也标记了 ACK

第一次挥手和第三次挥手,发送的是 FIN 包,即 TCP Flags 中的 FIN 标志置为 1。

但如果用 wireshark 抓包的话,会发现在标记 FIN 为 1 时,也将 ACK 置为了 1:

FIN和ACK同时标记

查阅资料后发现:RFC793 明确规定,除了第一个握手报文 SYN 除外,其它所有报文必须将 ACK = 1。

因为 TCP 是通过确认机制,来保证消息可靠传输的。只有当 ACK = 1 时,TCP 头部的确认号才有效。但是这块区域是固定的,是省不掉一定会有的,空着也是空着,不如一直使用,每一次 TCP 报文都携带确认信息。

等待 2 个 MSL

MSL(Max Segment Lifetime)报文最大存活时间,是指 TCP 报文在网络中最大的生存时间。

这个概念还跟网络层(IP 层)有点关系,比如网络包从一个路由器到另一个,兜兜转转,一直都到不了目的地,为了避免资源浪费,引入了 TTL 和 MSL 的概念,总之代表的含义是一个报文最久能在网络中待多久。

换个角度看待这个概念:如果发送了一个网络包,那么过了一个 MSL 的时间之后,网络中必然不存在这个包,要不然已到达目的地,要不然就被消灭了。

第四次握手之后,客户端会等待 2 个 MSL 的时间,在这段时间之内客户端处于 TIME_WAIT 状态,还可以接收服务端发送来的信息,但是超过这个时间之后,连接就彻底断开了,再也不处理任何发送来的 TCP 包了。

等待 2 个 MSL 的时间有两个原因:

  1. 以防第四次挥手 ACK 包没发送成功

    如果客户端第四次挥手发送的 ACK 包没有发送成功,多等 2 个 MSL 的时间还能抢救一下:

    MSL重传作用

  2. 避免两次连接串了

    如果没有多等 2 个 MSL 的过程,可能使两次连接的时间相隔很近,第二次连接接收到了第一次连接的 TCP 包。

    MSL两次连接串扰


这篇文章就总结到这里。

写的过程中一直觉得自己写得不好,概括能力和表达能力都不行,还是学习来源《深入理解 TCP 协议:从原理到实战》厉害,强烈推荐,写得真好。