TCP 的握手和挥手
Contents
背景
每次我面试初中级开发者的时候,我都很喜欢在面试中问一个看似很基础的问题:请你说一说 TCP 的握手和挥手的过程。大多数开发者都能很顺利地把协议的「形式」给表达出来,但是极少人(包括一些资深工程师)能够清晰地说出协议设计和实际使用的细节:
- 为什么是三次握手和四次挥手而不是其他数值 ?
- SYN 包选择序号有什么需要注意的地方吗 ?
- TCP 连接建立后的两个主机将会分别保存对方的什么信息 ?
- TCP 握手过程中会有哪些可见的开销 ?
- TCP 如何应对 SYN flood ?
- Linux socket 接口中有一个 backlog 参数,这个参数有何用处 ?
- 为什么四次挥手过程中的 TIME_WAIT 状态需要等待 2MSL 时间 ?一般 Linux 上这个时间是多长 ?
- 什么是 TCP 的半关闭状态 ?
等等。
我的看法是:对基础问题理解越深刻的人,技术功底就越扎实,系统设计能力就相对越靠谱。之所以这么说,是因为很多计算机相关的问题,其实来来去去就那几个基础问题,而这些基础性问题在不同的场景下被以不同的手段解决过很多遍。与其自己动手设计系统,还不如先深入理解下前人的一些设计以及设计背后所考虑的问题,这样反而效率更高。
本文尝试阐述 TCP 握手和挥手的一些细节设计。
TCP 的三次握手
完整的握手过程
一个完整的 TCP 三次握手流程如下图所示:
其中:
-
Server 处于 LISTEN 状态,即可被连接。此时 Client 发出一个 SYN 报文,并选择一个初始序号
ISN(c)
。此时称 Client 为主动开启者。TCP 规定,SYN 报文不能携带数据,但必须消耗一个序列号。Client 发出 SYN 报文之后进入SYN_SENT
状态; -
Server 收到 Client 的 SYN 包之后,如同意建立连接,则向 Client 发出确认报文并进入
SYN_RCVD
状态。确认报文中 SYN 和 ACK 都设置为 1,确认号是ISN(c)+1
(即表明已经收到 Client 序号为ISN(c)
的包),并选择 Server 的初始序列号ISN(s)
。同样地,这个报文也不能承载数据,但是要消耗一个序列号; -
Client 收到 Server 的确认后,再给 Server 发送一个对此报文的确认,此时自己的序列号为
ISN(c)+1
,确认号是ISN(s)+1
。发出确认包号,Client 便认为此次连接已经建立,进入 ESTABLISHED 状态。当 Server 收到 Client 的确认报文后,同样进入ESTABLISHED
状态。至此,TCP 的三次握手过程完结。TCP 规定:ACK 报文段可以携带数据,如果不携带数据则不消耗序号。 在这种情况下,下一个数据报文段的序号仍是ISN(c)+1
;
TCP 握手结束后,Client 和 Server 都会建立并维护 TCB(传输控制块),从而可以「记住」对方,这也就是为什么 TCP 是面向连接的协议。
为什么是三次握手
这里有一个很有意思的问题:为什么是三次握手 ?而不是两次、四次、五次握手 ?
其中,三次握手过程中的步骤 1 和 2 是很容易理解的:Client 向 Server 发出连接请求,Server 响应这个连接请求。为什么还需要步骤 3 ?
让我们先假设只有两次握手(即保留步骤 1 和 2,省略步骤 3),试想一个场景:Client 向 Server 发出连接请求 S1,但 S1 由于网络问题未到达 Server,于是 Client 便在发送超时后重新发送连接请求 S2。这时,连接建立成功。假设此次连接结束后,Server 又收到之前滞留于网络的 S1,于是 Server 会以为 Client 又向其重新建立了新的连接,于是发出确认报文并进入 ESTABLISHED
状态。但是,Client 此时并不会响应 Server 对 S1 的确认报文,所以 Client 并未进入 ESTABLISHED
状态,而 Server 却一直等待 Client 发送数据。这样便对 Server 的资源造成了浪费。引入三次握手便可解决上述问题。
那为什么不是四次握手,五次握手,甚至 N 次握手呢 ?Client 难道不需要等待步骤 3 发出的报文的确认吗 ?其实这也很容易理解:建立一次可靠的连接,至少需要三次交互,而资源有限,不可能无休止地对上一个交互进行确认,所以便只需要三次握手即可。
其实三次握手在其他场景下也以不同的形式呈现:
-
注册新账号:用户发出注册请求 -> 服务端发送确认邮件或者短信确认码 -> 用户回复确认邮件或填写短信验证码;
-
网络支付:用户发出付款请求 -> 银行向用户发送短信确认码 -> 用户填写确认码并提交支付;
等等。试想一下如果新账号注册只需要两次握手,那么将导致多少僵尸账户。
备注:上述说法不完全准确,后面有空在修订一下,谨慎参考。
握手过程的 ISN 生成
TCP 三次握手过程中不传输任何数据,但是每次传输至少都需要 20 字节的 IP 头部和 20 个字节的 TCP 头部,而且每次传输都需要经过经过链路和 IP 层,频繁的创建 TCP 连接累积的开销还是很可观的。所以,在实际工程使用中,会尽量避免反复创建 TCP 连接,而是创建了多条连接后反复使用。
每次 TCP 三次握手,Client 和 Server 都必须分别生成自己的初始序列号(ISN,Initial Sequence Number)。如果有人猜测出 ISN,便可以假冒 Client 或者 Server ,从而建立起恶意的 TCP 连接。所以 ISN 生成非常重要。早先时候,ISN 是一个 32 位的计数器,该数值每 4 微妙加 1。这样,ISN 就不会出现重叠的情况,但缺陷也很明显:不够随机。
现代系统通常采用半随机的方法选择 ISN。Linux 采用一个相对复杂的过程来选择 ISN。它采用基于时钟的方案,并且针对每一个连接为时钟设置随机的偏移量。随机偏移量是在连接标识(即连接四元组:{ClientIP,ClientPort,ServerIP,ServerPort}
)的基础上利用加密散列函数得到的。散列函数的输入每 5 分钟就会改变 1 次。
backlog 参数
backlog 直译为「积压」,其实就是待处理请求队列大小。
在 Linux 2.2 之后,区分出了两个待处理队列:
-
未完成连接队列(incomplete connection queue),即处于 SYN_RCVD 状态,可由
/proc/sys/net/ipv4/tcp_max_syn_backlog
进行控制(默认为 1000); -
已完成连接队列(completed connection queue),已经完成三次握手,等待应用层的接受,即处于 ESTABLISHED 状态,,可通过
/proc/sys/net/core/somaxconn
和listen()
控制(默认为 128);
典型地,用这个两个队列建立连接时所交换的分组如下图所示:
当来自 Client 的 SYN 到达时,TCP 在未完成队列中创建一个新项,然后响应三次握手的第二个报文。这一项一直保留在未完成连接队列中,直到三次握手成功或者该项超时为止。如果三次握手正常完成,该项就从未完成连接队列中移到已完成连接队列的队尾。当进程调用 accept()
时,已完成连接队列中的队头项将返回给进程,如果队列未空,进程继续投入睡眠。
如果三次握手正常,则未完成连接队列中的任何一项在其中的存留时间就是一个 RTT。
SYN flood 攻击
从 TCP 三次握手的过程中可以看出,TCP 对连接的建立并无任何身份检验机制,这就使得某些恶意攻击者可大量发起 SYN 请求到目标系统,使得系统在半连接状态下分配内存(即 TCB 结构),当系统内存耗尽时,将拒绝为后续合法连接请求服务。
有几种手段可以有效缓解这一问题,比较典型的有 SYN Cache 和 SYN Cookie。
SYN Cache 指的是:当收到 SYN 报文的时候,先不急着分配 TCB,而是回应一个 SYN ACK ,并在一个哈希表(Cache)中保存这种半连接信息,直到收到正确的 ACK 后才分配 TCB。但是,某些 SYN flood 会智能地回复 ACK,从而使服务端真正建立 TCP 连接,此时 SYN Cache 就很难避免此类攻击。这时就可用上 SYN Cookie 算法。
SYN Cookie 指的是:当服务端收到 SYN 后会采用如下方法来设置 ISN:
- 令 t 为一个缓慢递增的时间戳(通常是
time() >> 6
,提供 64 秒的分辨率); - 令 m 为 MSS;
- 令 s 是一个加密散列函数对连接标识与 t 值的散列值;
则此时 ISN (即 SYN Cookie)为:
- 头 5 位:t mod 32;
- 中 3 位:m 编码后的数值;
- 末 24 位:s 本身;
由于 m 必须用 3 位编码,所以服务器在启用了 SYN Cookie 后只能发送 8 种不同的数值。
再接收到 ACK 后,服务器可对其以下检查:
-
根据当前时间以及 t 来检查连接是否过期;
-
重新计算 s 来确认这是不是一个有效的 SYN Cookie;
-
从 3 位编码中解码 m 以重建队列;
采用 SYN Cookie,当前连接的大部分信息都被编码并保存在 SYN+ACK 报文段的 ISN 中,则 Server 不需要为进入的连接请求分配任何存储资源,只有当 SYN+ACK 报文段被确认后才会真正分配内存。
但该方法有两个缺陷:
-
需要对 MSS 进行编码,这就导致无法使用任意大小的 MSS;
-
计数器会回绕,连接建立周期会因周期非常长(大于 64 秒)而无法正常工作;
因此 SYN cookie 很多时候不是一种默认策略。
TCP 的四次挥手
完整的握手过程
与 TCP 三次握手相比,四次握手就显得相对要复杂不少。
一个完整的 TCP 四次挥手的过程如下图所示:
其中:
-
TCP 连接的主动关闭者 Client 向 Server 发送一个 FIN 报文,其中还包含了一个 ACK 段用于确认对方最近一次发来的数据,标记为 L。Client 发送完 FIN 报文后进入
FIN_WAIT_1
状态; -
TCP 连接的被动关闭者 Server 将 K 的数值加 1 作为响应的 ACK,表明它已经成功接收到主动关闭者发送的 FIN 并进入
CLOSE_WAIT
状态。此时,上层的应用程序会被告知连接的另一端已经提出关闭的请求。这是 TCP 连接处于半关闭(half-close)状态,即 Client 不再会向 Server 发送数据,但是 Server 仍可向 Client 发送数据,而且 Client 也会对此数据做出确认; -
当 Server 已经没有任何数据要发给 Client 后,Server 会向 Client 发出 FIN 报文,受半关闭状态发送数据的影响,该报文段的序列号为 M(如果半关闭状态没有发送数据,M == L),确认号为 K+1,此时 Server 进入
LAST_ACK
状态; -
Client 收到 Server 的 FIN 报文后,发出确认报文并进入
TIME_WAIT
状态。此时 Client 端的 TCP 连接还没有被释放,必须经过 TIME-WAIT timer 设置的时间 2MSL 后,才进入CLOSED
状态。Server 一收到 Client 的确认报文就进入CLOSED
状态,即 Server 端结束 TCP 连接要比 Client 要早一些。
为什么要等待 2MSL 时间
时间 MSL 称为最长报文段寿命(Maximum Segment Lifetime),它代表任何报文段在被丢弃前在网络中允许存在的最长时间,一般可以是 1 分钟或者 30 秒(起初 RFC793 建议是 2 分钟)。在 Linux 上,可以通过修改 /proc/sys/net/ipv4/tcp_fin_timeout
来配置 MSL。
之所以要让 Client 等待 2MSL 才进入 CLOSED
状态,有以下原因:
-
为了保证 Client 发送的最后一个 ACK 报文能够到达 B。当该 ACK 报文丢失,将使得处于
LAST_ACK
状态的 Server 收不到已发送 FIN+ACK 报文的确认,从而触发 Server 超时重传这个报文,而 A 能在 2MSL 时间内收到这个重传的 FIN+ACK。接着 Client 重传一次确认,重置 2MSL 计时器。如果 A 在进入TIME_WAIT
状态后不等待一段时间而是直接释放连接,那么就无法收到 Server 重传的 FIN+ACK 报文,因而也不会再发送一次确认报文段。这样 Server 就无法正常进入CLOSED
状态; -
防止已失效的连接请求报文出现在本连接中。当处于
TIME_WAIT
状态时,该 TCP 连接标记为不可用,直到TIME_WAIT
结束。假如没有TIME_WAIT
,试想一下重新启动了一条相同的 TCP 连接,则之前在网络中迷途的上一次连接的报文将有可能干扰现在的连接。为避免这种情况,等待 2MSL 将足够让这类报文在网络中被丢弃。
如果一个端口号处于 TIME_WAIT
状态,则该端口号在此期间将无法再次使用,但可以用 Linux Socket API 中的 SO_REUSEADDR
来绕开这一限制。
为什么要是四次挥手
建立一个连接需要三次握手,而终止一个连接需要四次挥手,这是由于 TCP 的半关闭造成的。
如前文所示,三次握手中的第三次握手其实是为了 Server 对 Client 的连接请求再做一次确认,而关闭动作则无需这一步交互,一来一回的挥手即可。由于 TCP 连接是全双工模式,因此每个方向都必须单独地进行关闭。这样便需要四次交互:主动关闭者关闭连接(FIN 和 ACK),被动关闭者关闭连接(FIN 和 ACK)。
收到一个 FIN 只意味着在这个方向上没有数据流动,但另一个方向仍可继续发送数据。
同时打开与同时关闭
备注:后面有空再补充一下。
TCP 状态变迁图
一个经典完整的 TCP 有限状态变迁图(旧版的《TCP/IP 详解卷 1》)如下所示:
一个实际的抓包例子
我们在一台机器上打开 23001 端口,并在该机器上连接 23001 端口并传输数据,其抓包如下:
|
|