4. TCP四次挥手与资源释放:挥手过程详解、TIME_WAIT状态的影响、端口耗尽问题、SO_REUSEADDR与SO_LINGER配置
TCP连接关闭,很多人觉得不就是发个FIN包嘛。但实际线上踩过的坑,十有八九都出在这个阶段。我见过不少高并发服务,莫名其妙端口不够用了,或者新连接连不上,追根溯源都是挥手阶段没处理好。
今天咱们就把四次挥手掰开揉碎,看看每个状态怎么来的,TIME_WAIT为什么让人又爱又恨,以及怎么用套接字选项给自己留条后路。
4.1 四次挥手:谁先挥手谁难受
先看标准流程。假设客户端主动关闭连接:
客户端(主动关闭) 服务端(被动关闭)
| |
| —— FIN (seq=x) ——————————→ | 第一次挥手
| |
| ←— ACK (seq=y, ack=x+1) —————— | 第二次挥手
| |
| ←— FIN (seq=z, ack=x+1) —————— | 第三次挥手
| |
| —— ACK (seq=x+1, ack=z+1) ——————→ | 第四次挥手
| |
| (进入TIME_WAIT,等待2MSL) |
这里有个关键点:第二次和第三次挥手之间,服务端可能还在发数据。也就是说,ACK和FIN是分开的,不能合并。这就是为什么叫四次挥手,而不是两次。
核心认知:主动关闭方在发送最后一个ACK后,必须进入TIME_WAIT状态,等待2MSL(Maximum Segment Lifetime,报文最大生存时间)。这个等待不是可选的,是协议强制要求。
为什么强制等?两个原因:
- 保证最后一个ACK能到达对端。如果ACK丢了,对端会重发FIN,你还在TIME_WAIT就能重新响应。
- 让网络中所有迷路的报文都消失。2MSL足够一个报文在网络中存活并最终被丢弃,避免影响后续新连接。
嗯,道理都懂。但线上问题恰恰出在这个"强制等待"上。
4.2 TIME_WAIT:高并发下的隐形杀手
我接手过一个网关服务,压测到每秒8000连接时,突然大量报错"Address already in use"。查了系统日志,发现TIME_WAIT状态的连接堆到了6万多。
为什么会这样?
每次主动关闭连接,这个连接的四元组(源IP、源端口、目标IP、目标端口)就会被TIME_WAIT锁住2MSL。Linux默认的MSL是30秒,2MSL就是60秒。如果你的服务每秒创建并关闭8000个连接,60秒内就有48万个端口被占用。
而系统可用端口范围通常是32768到60999,也就2万多个。端口耗尽只是时间问题。
| 问题 | 产生原因 | 典型表现 |
|---|---|---|
| 端口耗尽 | 主动关闭方端口被TIME_WAIT占用,无法复用 | connect()返回EADDRNOTAVAIL |
| 连接失败 | 新连接的四元组与TIME_WAIT中的冲突 | bind()返回EADDRINUSE |
| 内存占用 | 每个TIME_WAIT连接占用少量内核内存 | slab内存持续增长 |
避坑指南:我曾经在线上直接调小tcp_fin_timeout,以为能解决问题。结果导致数据丢失,因为TIME_WAIT太短,重传的FIN包被当成新连接处理了。这个参数不能乱改。
4.3 端口耗尽:不只是数量问题
端口耗尽不只是"端口不够用"这么简单。你想想看,即使端口够用,如果新连接恰好复用了TIME_WAIT中的四元组,会发生什么?
假设连接A(192.168.1.1:50000 → 10.0.0.1:80)处于TIME_WAIT。新连接B也想用同样的四元组。如果B的SYN包到达时,A的迷路报文恰好也到了服务端,服务端就会把迷路报文当成B的数据,导致数据错乱。
所以协议规定:TIME_WAIT期间,四元组不可复用。
但高并发场景下,我们真的需要这么严格吗?
答案是:看场景。如果是客户端主动关闭,且服务端是无状态的HTTP服务,TIME_WAIT的防护其实有点过度。因为HTTP请求的迷路报文最多就是一些GET请求,不会造成数据损坏。
4.4 SO_REUSEADDR:打破TIME_WAIT的枷锁
SO_REUSEADDR是我用得最多的套接字选项。它的作用说白了就是:允许在TIME_WAIT状态下复用本地地址和端口。
int opt = 1;
setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
这个选项有两个典型使用场景:
- 服务端重启:服务崩溃后立即重启,原来的端口还在TIME_WAIT,没有SO_REUSEADDR就bind失败。我习惯在所有服务端socket上都设置这个选项。
- 客户端大量短连接:客户端主动关闭连接后,允许新连接复用刚才的端口。
个人经验:SO_REUSEADDR在Linux上对客户端bind()也有效。但要注意,它只允许复用TIME_WAIT状态的地址,不允许复用ESTABLISHED状态的。这个区分很重要,别搞混了。
但SO_REUSEADDR不是银弹。它解决的是"地址可用性"问题,不是"连接可靠性"问题。如果业务要求数据零丢失,光靠这个选项不够。
4.5 SO_LINGER:优雅还是暴力
SO_LINGER控制的是close()的行为。它的结构体长这样:
struct linger {
int l_onoff; // 0=关闭,非0=开启
int l_linger; // 超时时间,秒
};
三种配置对应三种行为:
| l_onoff | l_linger | close()行为 | 风险 |
|---|---|---|---|
| 0 | 忽略 | 默认行为,立即返回,内核继续发送未发送数据 | 无 |
| 非0 | 0 | 强制关闭,立即发送RST,丢弃未发送数据 | 数据丢失 |
| 非0 | >0 | 阻塞直到数据发送完成或超时 | 阻塞线程 |
我见过有人为了规避TIME_WAIT,把SO_LINGER设为{1, 0}。这样close()会直接发RST,跳过四次挥手,自然就没有TIME_WAIT了。
警告:l_linger=0是暴力关闭。我曾经在一个支付系统里看到有人这么用,结果丢了一笔订单的确认数据。支付对账时发现少了钱,查了两天才找到原因。非必要不要用这个配置。
那l_linger>0呢?这个配置会让close()阻塞,直到数据发送完成或者超时。看起来安全,但高并发下线程会被卡住。我一般只在文件传输这类场景用,HTTP短连接基本不用。
4.6 实战配置建议
说了这么多,到底怎么配?我给出几个经过线上验证的方案:
- HTTP/1.1短连接服务端:设置SO_REUSEADDR,允许服务端重启时快速恢复。客户端那边,尽量用连接池,减少主动关闭。
- RPC长连接客户端:不要主动关闭连接,让心跳机制维持连接。如果必须关闭,用SO_LINGER{1, 1}确保数据发完。
- 代理服务器(Nginx类型):开启SO_REUSEADDR,同时调整net.ipv4.ip_local_port_range扩大端口范围。我习惯设成1024到65535。
- 压测工具:可以临时用SO_LINGER{1, 0},因为压测不关心数据完整性。但线上千万别这么干。
一句话总结:TIME_WAIT是协议给你的安全带,别轻易剪断。SO_REUSEADDR是安全带的延长扣,让你活动范围大一点。SO_LINGER是安全带的紧急切割刀,不到万不得已别用。
4.7 挥手过程全貌图
下面这张图把四次挥手、状态转换、以及TIME_WAIT的影响范围画在一起了。你可以看到主动关闭方和服务端各自经历了哪些状态,以及TIME_WAIT如何影响端口复用。
从图上你能清楚看到:主动关闭方经历了FIN_WAIT_1、FIN_WAIT_2、TIME_WAIT三个状态,而被动关闭方只经历了CLOSE_WAIT和LAST_ACK。TIME_WAIT只出现在主动关闭方,这就是为什么我们常说"谁先挥手谁倒霉"。
好了,四次挥手和资源释放的核心内容就这些。记住一点:理解TIME_WAIT的本质,比背一堆配置参数重要得多。参数可以查手册,但理解不到位,出了问题你都不知道从哪查起。