上一篇《第二十九篇:图解TCP三次握手,看过不会忘,从底层说清楚,TCP系列四》说了TCP的三次握手,接下来我将讲解TCP四次挥手。
既然有连接就有断开,谈到这里,有的同学可能会想,不就是TCP的断开吗?简单,四次挥手解决问题,只要了解了四次的挥手过程就掌握了TCP的断开,我只能说:同学,too young,too simple;且让我提出几个问题去思考:
- 连接断开是谁先发起的?
- 如果断电/断网了,连接会断开吗?
- 程序奔溃了,连接会断开吗?
- 什么情况下没有四次挥手连接也会断开?
- 连接断开发生在应用层还是传输层?
只有掌握了断开的底层逻辑,才能在工作中遇到问题不慌不忙,做到游刃有余的解决问题,轻松拿高薪。
一、理想状态下,一个 TCP 连接可以被长期保持
前面我们说到,TCP连接其实就是通过三次握手,在通信双方的主机里面保存对应的通信信息,即⽤于保证可靠性和流量控制而维护的某些状态信息称为连接也可以理解成“通信线路”;这些状态信息包括Socket、序列号和窗⼝⼤⼩等。如下图所示:
- 套接字:由 IP 地址、端⼝号以及传输层协议(TCP或UDP)组成
- 序列号:⽤来解决乱序问题等
- 窗⼝⼤⼩:⽤来做流量控制
所以理论上,如果通信双方的“连接信息”不变,一直保存在双方主机,则理论连接会被长期保存。
但是现实却往往是很残酷的,连接经常或被动断开,比如断电(TCP连接信息缓存丢失,导致连接遭到损坏);或一方主机根据情况主动断开,比如不想浪费资源(内存和断开是有限的),一方主机在发送完资源后,用程序主动关闭连接。
二、那么下面就让我们一起来梳理哪些情况会发生连接断开
- 通信双方信息交互完后,一方主动调用TCP软件的“连接断开方法”进行TCP四次挥手断开连接
- 程序奔溃、使用CTRL + C、kill -9 方法杀死进程
- 硬件奔溃:主机损坏、断网等
- 一方断电或宕机
- 防火墙关闭连接
- 其他情况
三、会正常执行TCP四次挥手断开连接的情况有下面几种
1.通信双方信息交互完后,一方主动调用TCP软件的“连接断开方法”
比如,应用程序使用HTTP协议进行信息交互,当交互完后,客户端因为信息发送完成,主动调用close方法或实现http的程序包因为检测到通信双方长时间没有信息交互而主动发起连接断开,这些都发生在应用层,就是程序员开发的应用程序。
2.程序奔溃、使用CTRL + C、kill -9 方法杀死进程
因为应用程序没有主动调用TCP软件的断开流程,将有OS系统自动触发TCP四次挥手断开流程。
3.防火墙关闭连接
防火墙触发TCP四次挥手断开流程。
四、TCP正常断开的四次挥手流程
1. 客户端打算关闭连接,此时会发送⼀个 TCP ⾸部 FIN 标志位被置为 1 的报⽂,也即 FIN 报⽂,之后客户端进⼊ FIN_WAIT_1 状态。
2. 服务端收到该报⽂后,就向客户端发送 ACK 应答报⽂,接着服务端进⼊ CLOSED_WAIT 状态。
3. 客户端收到服务端的 ACK 应答报⽂后,之后进⼊ FIN_WAIT_2 状态。
4. 等待服务端处理完数据后,也向客户端发送 FIN 报⽂,之后服务端进⼊ LAST_ACK 状态。
5. 客户端收到服务端的 FIN 报⽂后,回⼀个 ACK 应答报⽂,之后进⼊ TIME_WAIT 状态,之后的2MSL时间内都是time_wait状态。
6. 服务器收到了 ACK 应答报⽂后,就进⼊了 CLOSED 状态,⾄此服务端已经完成连接的关闭。
7. 客户端在经过 2MSL ⼀段时间后,⾃动进⼊ CLOSED 状态,⾄此客户端也完成连接的关闭。
你可以看到,每个⽅向都需要⼀个 FIN 和⼀个 ACK,因此通常被称为四次挥⼿。
而在日常的tcp连接断开中,一般只有三次挥手,这是因为如果第二次与第三次挥手之间没有数据发送,那么被动断开连接的一方就可能会把第二次的 ACK 与 第三次的 FIN 合并为一次挥手。四次挥手就会变成三次挥手。
五、为何需要四次挥手,而不是像建立连接一样三次挥手?
如果第二次与第三次挥手之间没有数据发送,那么被动断开连接的一方就可能会把第二次的 ACK 与 第三次的 FIN 合并为一次挥手。四次挥手就会变成三次挥手。
- 延时确认,捎带确认
在建立连接的第三次握手的时候,有可能带上数据;在连接断开的四次挥手的第二次挥手,即连接断开被动方在接收到FIN报文的时候,会给断开主动方发送ACK报文,这个报文不会立即发送出去,而是等待200ms(推荐值,但是这个值不能高于500ms),如果在这段时间有用户数据需要发送则一同随着这个ACK发送。
六、为何需要time_wait状态?
主动发起关闭连接的⼀⽅,才会有 TIME-WAIT 状态。TIME _WAIT状态也称为2MSL等待状态。
MSL(Maximum Segment Lifetime)是最大报文段生存时间,它是任何报文段被丢弃前在网络内生存的最长时间,RFC1122建议是2分钟,但BSD传统实现采用了30秒。
当连接转移到TIME_WAIT状态时,即连接主动关闭时,定时器启动,为两倍的MSL。定时器超时,这时才能重新使用之前连接使用的插口(源IP、端口和目的IP和端口的组合即插口也叫四元组)。这也是为了避免一些意想不到的边界情况。
1. time_wait帮助连接正确关闭
断开连接的被动端要确保fin报文正确到达主动端,需要主动端回复ack报文通知被动端fin报文已经成功到达,否则被动端会继续发送fin报文,如果没有time_wait,主动端接收到fin报文回复ack报文,然后进入closed状态,如果ack报文丢失,被动端超时重发fin报文,得到的将是RST报文,这不符合正确关闭的目标。
注:rst 报文在客户端会显示报错信息Connection closed by foreign host。
2. time_wait防止两端在就连接未关闭完全期间在同一个插口建立新连接,导致新旧数据错乱
如果没有time_wait,主动端的ack丢失,同时被动端再次发送fin报文,此时主动端在该插口的状态为关闭,建立新的连接,可能会导致迟到的fin报文使新连接困惑进而导致异常。
七、为何是等待2MSL?
因为断开发起方(主动端)回复ack报文在网络上的过期时间是一个MSL,被动端在 1MSL 内没有收到主动端发出的 ACK 确认报文,就会再次向主动端发出 FIN 报文,被动端重发fin报文在网络上的过期时间也是一个MSL,如果2MSL时间内没有再次收到被动端发送来的fin报文,则主动端认为被动端已经接收到ack报文,关闭了连接,则主动端也关闭连接。
在2MSL时间段内,如果断开主动方(主动端)再次接收到被动端的fin报文,说明服务器端由于各种原因没有接收到客户端发出的 ACK 确认报文。客户端再次向服务器端发出 ACK 确认报文,计时器重置,重新开始 2MSL 的计时。直到2MSL时间内未收到fin报文,正确关闭连接。
假设,被动端的fin报文始终没有收到ack,而2MSL内主动端也没收到fin报文,则被动端在重试了一定次数后也会断掉连接,如果在重试过程中,fin报文又到达主动端,而主动端也过了2MSL,则此时被动端将收到RST报文,使被动端的连接关闭。
TCP定义:
- 在2MSL时间内,连接上的插口不能再继续建立连接
- 平静时间
对于来自某个插口的较早连接的迟到报文段, 2MSL等待可防止将它解释成使用相同插口
对的新连接的一部分。但这只有在处于 2 M S L等待连接中的主机处于正常工作状态时才有效。
如果使用处于2 M S L等待端口的主机出现故障,它会在 M S L秒内重新启动,并立即使用故
障前仍处于2 M S L的插口对来建立一个新的连接吗?如果是这样,在故障前从这个连接发出而迟到的报文段会被错误地当作属于重启后新连接的报文段。无论如何选择重启后新连接的初
始序号,都会发生这种情况。
为了防止这种情况,RFC 793指出T C P在重启动后的M S L秒内不能建立任何连接。这就称
为平静时间(quiet time)。
只有极少的实现版遵守这一原则,因为大多数主机重启动的时间都比MSL秒要长。
八、定时器
1.建立连接定时器 --- Connection-establishment Timer
如果client在连接server的时候, 在发送SYN的时候, 会启动一个定时器(在 3.10 版本中首次超时时间是 1 s,一些老版本中是 3 s。),如果SYN包丢失了, 那么1秒以后会重新发送SYN包的(当然还会启动一个新的定时器, 设置成2秒超时),当然也不会一直没完没了的发SYN包, 在/proc/sys/net/ipv4/tcp_syn_retries 可以设置到底要重新发送几次SYN包。
2. 重传定时器---Retransmission Timer
对于TCP发送出去的数据包, 需要等待对端发来ACK才能从内存里面删除, 那么如果对端没有发送ACK怎么办? 重传。
在发送数据的同时,再设置一个超时时间RTO,如果在这个超时时间内, 没有收到ACK,那么就重传刚才发送的数据。
3. FIN_WAIT_2定时器
在主动关闭的一端调用完close以后(发FIN包给对端, 并且收到对端对FIN的ACK)则进入到FIN_WAIT_2状态, 那么这个时候如果和对端之间的网络坏了或者对端程序有问题了一直不close, 或者对端机器直接掉电了, 本端不能一直傻等, 所以就需要这个定时器.
如果在这个定时器超时的时候,还是没收到对端的FIN包, 那么不好意思, 不等了, 直接释放这个链接。
这个定时器的时间是多少呢?可以从/proc/sys/net/ipv4/tcp_fin_timeout里面看到。
4. 2MSL定时器
在2MSL时间段内,如果断开主动方(主动端)再次接收到被动端的fin报文,说明服务器端由于各种原因没有接收到客户端发出的 ACK 确认报文。客户端再次向服务器端发出 ACK 确认报文,计时器重置,重新开始 2MSL 的计时。直到2MSL时间内未收到fin报文,正确关闭连接。
5. 坚持定时器---Persist Timer
如果某一时刻,一方发现自己的 socket read buffer 满了,无法接受更多的TCP data,此时就是在接下来的发送包中指定通告窗口的大小为0,这样对方就不能接着发送TCP data了。如果socket read buffer有了空间,可以重设通告窗口的大小在接下来的 TCP segment 中告知对方。可是万一这个 TCP segment 不附带任何data,所以即使这个segment丢失也不会知晓(ACKs are not acknowledged, only data is acknowledged)。对方没有接受到,便不知通告窗口的大小发生了变化,也不会发送TCP data。这样双方便会一直僵持下去。
TCP协议采用这个机制避免这种问题:TCP使用了零窗口探测技术(Zero Window Probe,缩写为ZWP),也就是说,接收端在接收窗口变为0后会通知发送端,发送端发现接收端窗口大小变为0,会启动一个定时器,这个定时器就是Persist Timer,在定时器超时后,会发探测报文给接收方,让接收方来ack他的Window尺寸,一般这个值会设置成3次,每次大约30-60秒(不同的实现可能会不一样)。如果3次过后还是0的话,有的TCP实现就会发RST把链接断了。
6. 保活计时器---Keepalive Timer
如果客户端和服务端长时间没有数据交互,那么需要保活定时器来判断是否对端还活着,但是这个其实很不实用,因为默认是2小时没有数据交互才探测,时间实在是太长了。
具体实现方法:TCP每隔一段时间(tcp_keepalive_intvl)会发送一个特殊的 Probe Segment,强制对方回应,如果没有在指定的时间内回应,便会重传,一直到重传次数达到 tcp_keepalive_probes 便认为对方已经crash了。
# TCP KeepAlive 机制,保活时间、保活时间间隔和保活探测次数
net.ipv4.tcp_keepalive_time
net.ipv4.tcp_keepalive_intvl
net.ipv4.tcp_keepalve_probes
在 Linux 上可以通过如下文件查看
cat /proc/sys/net/ipv4/tcp_keepalive_time
cat /proc/sys/net/ipv4/tcp_keepalive_intvl
cat /proc/sys/net/ipv4/tcp_keepalive_probes
默认设置是 7200 秒(2 小时)、75 秒和 9 次探测。
如果使用 TCP 自身的 keep-Alive 机制,在 Linux 系统中,最少需要经过 2 小时 11 分 15 秒才可以发现一个“死亡”连接。
这个时间是怎么计算出来的呢?
其实是通过 2 小时,加上 75 秒乘以 9 的总和。
如果你真的要确认对端是否活着, 那么应该在应用层自己实现心跳包,而不是依赖于这个保活定时器。
实际上,对很多对时延要求敏感的系统中,这个时间间隔是不可接受的。
如果开启了 TCP 保活,需要考虑以下⼏种情况:
第⼀种,对端程序是正常⼯作的。当 TCP 保活的探测报⽂发送给对端, 对端会正常响应,这样 TCP 保活时间会被重置,等待下⼀个 TCP 保活时间的到来。
第⼆种,对端程序崩溃并重启。当 TCP 保活的探测报⽂发送给对端后,对端是可以响应的,但由于没有该连接的有效信息,会产⽣⼀个 RST 报⽂,这样很快就会发现 TCP 连接已经被᯿置。
第三种,是对端程序崩溃,或对端由于其他原因导致报⽂不可达。当 TCP 保活的探测报⽂发送给对端后,⽯沉⼤海,没有响应,连续⼏次,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
7. 延迟应答定时器 --- delayed ACK timer
顾名思义, 这个定时器是在延迟应答的时候使用的。
为什么要延迟应答呢?
比如客户端发一段数据给服务端, 服务端本应该立刻回ACK给客户端的, 延迟应答是为了提高网络传输的效率, 比如服务端收到客户端的数据后, 不是立刻回ACK给客户端, 而是等一段时间(一般最大200ms),这样如果服务端要是有数据需要发给客户端,那么这个ACK就和服务端的数据一起发给客户端了, 这样比立即回给客户端一个ACK节省了一个数据包。
参考:
【tcp】TCP协议中的7种定时器 - 简书
TCP协议的超时详解 - 简书
九、非四次挥手连接损坏
1)硬件奔溃:主机损坏、断网等
2)一方断电或宕机
1. 没有数据传输的时候主机奔溃
如果对方在开启了TCP keepalive 保活,那么如果在机器停机期间,如果对方发送了保活探测报文,此时未奔溃端的探测报文会石沉大海,不会被响应,连续几次后,达到保活探测次数后,TCP 会报告该 TCP 连接已经死亡。
# TCP KeepAlive 机制,保活时间、保活时间间隔和保活探测次数
net.ipv4.tcp_keepalive_time
net.ipv4.tcp_keepalive_intvl
net.ipv4.tcp_keepalve_probes
在 Linux 上可以通过如下文件查看
cat /proc/sys/net/ipv4/tcp_keepalive_time
cat /proc/sys/net/ipv4/tcp_keepalive_intvl
cat /proc/sys/net/ipv4/tcp_keepalive_probes
2. 有数据传输的时候主机奔溃
数据会超时重传,数据报文的重传次数达到一定阈值后,内核就会判定出该 TCP 有问题,然后通过 Socket 接口告诉应用程序该 TCP 连接出问题了,一般就是 ETIMEOUT 错误状态码。
那具体重传几次呢?
在 Linux 系统中,提供一个叫 tcp_retries2 配置项,默认值是 15,可以通过
Cat /proc/sys/net/ipv4/tcp_retries2 查看和设置。
这个内核参数是控制,在 TCP 连接建立的情况下,超时重传的最大次数。
不过 tcp_retries2 设置了 15 次,并不代表 TCP 超时重传了 15 次才会通知应用程序终止该 TCP 连接,内核还会基于「最大超时时间」来判定。
超时重传阶段每一轮的超时时间都是倍数增长的,比如第一次触发超时重传是在 2s 后,第二次则是在 4s 后,第三次则是 8s 后,以此类推。
但是这个超时时间不会无限增加下去,会受到最大超时时间的限制,超时时间的计算根据一下公式来计算:
其中rto_base为200ms;TCP_RTO_MAX为120000ms。
机器恢复后,因为异常导致本方TCP连接信息丢失,重启完成后,收到之前 TCP 连接的报文,都会回复 RST 报文,以断开连接。
十、解决连接断开过程中的问题
1. time_wait 堆积过多如何解决
危害:
① 内存资源的占用
② 对端口资源的占用,一个 TCP 连接至少消耗「发起连接方」的一个本地端口
要直到端口资源是有限的(只有65536),如果被time_wait占用过多,占满了所有端口资源,则会导致无法创建新连接,影响正常的使用。
原因:
可能是高并发情况下,高并发让服务器在短时间范围内同时占用大量端口,而业务处理+传输数据的时间 远远小于 TIME_WAIT的2MSL超时时间,导致很多time_wait对接,影响其他正常的连接。
解决:
① 修改内核参数---设置time_wait的连接重用或快速回收
- net.ipv4.tcp_tw_reuse = 1
表示开启重用。允许将time_wait sockets重新用于新的TCP连接,默认为0,表示关闭;
因为重用插口,新连接不能处理旧数据,TCP需要能判断到达的数据是否为旧数据,实现原理是根据时间戳判断是否是延迟的数据,如果是,则丢弃。要实现这个功能就需要开启TCP对时间戳的支持,这个内核参数是:net.ipv4.tcp_timestamps=1(默认即为 1,开启);这个时间戳的字段是在 TCP 头部的「选项」⾥,⽤于记录 TCP 发送⽅的当前时间戳和从对端接收到的最新时间戳。
由于引⼊了时间戳,我们在前⾯提到的 2MSL 问题就不复存在了,因为重复的数据包会因为时间戳过期被⾃然丢弃,不会对新旧数据产生混淆。
- net.ipv4.tcp_tw_recycle = 1
表示开启TCP连接中time_wait sockets的快速回收,默认为0,表示关闭。
- net.ipv4.tcp_max_tw_buckets
这个值默认为 18000,当系统中处于 TIME_WAIT 的连接⼀旦超过这个值时,系统就会将后⾯的 TIME_WAIT 连接状态重置。
这个⽅法过于暴⼒,⽽且治标不治本,带来的问题远⽐解决的问题多,不推荐使⽤。
② 客户端:短链接改长连接
HTTP 请求的头部, connection 设置为 keep-alive, 保持存活一段时间;
长连接从根本上减少了关闭连接的次数,减少了TIME_WAIT状态的产生数量,在高并发的系统中非常有效,现在的浏览器, 一般都这么进行了 。
③ 缩减 time_wait 时间
例如: 设置为 1 MSL(即, 2 mins)
④ 不主动关闭连接
不主动关闭socket,就不会产生TIME_WAIT状态
⑤ 程序中使⽤ SO_LINGER
我们可以通过设置 socket 选项,来设置调⽤ close 关闭连接⾏为。
struct linger so_linger;
so_linger.l_onoff = 1;
so_linger.l_linger = 0;
setsockopt(s, SOL_SOCKET, SO_LINGER, &so_linger,sizeof(so_linger));
如果 l_onoff 为⾮ 0, 且 l_linger 值为 0,那么调⽤ close 后,会⽴该发送⼀个 RST 标志给对端,该 TCP 连接将跳过四次挥⼿,也就跳过了 TIME_WAIT 状态,直接关闭。
但这为跨越 TIME_WAIT 状态提供了⼀个可能,不过是⼀个⾮常危险的⾏为,不值得提倡。
参考:
服务器出现大量TIME_WAIT状态怎么解决_最大进程打开文件timewait-CSDN博客
2. close_wait 堆积过多如何解决
危害:
使服务端的服务不可用。
原因:
被服务端没有发送fin报文导致,没有发送的原因一般都是程序因为被阻塞(比如调用远程资源)或者异常代码死循环,让客户端超时关闭,客户端发送关闭请求,服务端回复ack,但是由于各种原因导致服务端程序一直在运行阻塞无法发出fin报文。
解决:
① 修改内核参数
开启TCP保活检测
② 资源的访问一定要设置访问超时时间
比如数据库访问超时,第三方资源访问超时等。
③ 代码要规范,资源要牢记释放