4、TCP四次挥手:四次挥手的过程详解,TIME_WAIT状态的作用与危害,端口复用选项
4.1 挥手告别:四次挥手的完整过程
TCP连接建立需要三次握手,断开连接则需要四次挥手。为什么是四次?说白了,因为TCP连接是全双工的——双方都能发数据,也能收数据。断开时,每一方向都要单独说再见。
我刚开始学TCP时,总觉得四次挥手比三次握手复杂。其实你想想看,握手是双方同时确认,挥手则是轮流确认。过程是这样的:
- 第一次挥手(FIN):主动关闭方(比如客户端)发送一个FIN包,告诉对方:“我没数据要发了,准备关掉我的发送通道。”
- 第二次挥手(ACK):被动关闭方(比如服务端)收到FIN后,回复一个ACK包,说:“收到,我知道你没数据了。但我这边可能还有数据要发,你先等着。”
- 第三次挥手(FIN):被动关闭方把剩余数据发完后,也发送一个FIN包,告诉对方:“我这边也发完了,可以关了。”
- 第四次挥手(ACK):主动关闭方收到FIN后,回复一个ACK包,然后进入TIME_WAIT状态。
嗯,这里有个关键点:第二次和第三次挥手之间,可能隔了一段时间。因为被动关闭方可能还有数据没发完,不能立刻关。这就是为什么需要四次,而不是三次。
核心要点:四次挥手本质上是两次“请求-确认”过程。第一次确认关闭发送通道,第二次确认关闭接收通道。双方都确认后,连接才算真正断开。
4.2 挥手的细节:状态变迁与序列号
每次挥手,都伴随着TCP状态的变化。我画了一张图,帮你理清这个过程:
这张图里,我特意把状态变迁画出来了。你看,客户端在发送FIN后进入FIN_WAIT_1,收到ACK后进入FIN_WAIT_2,收到对方的FIN后回复ACK,然后进入TIME_WAIT。服务端收到FIN后进入CLOSE_WAIT,发送FIN后进入LAST_ACK,收到ACK后关闭。
个人经验:我在排查一个HTTP长连接问题时,发现服务端大量连接卡在CLOSE_WAIT状态。原因是服务端代码没有正确关闭socket——只关闭了读端,没关闭写端。记住:收到FIN后,一定要调用close()或shutdown(SHUT_WR)来触发第三次挥手。
4.3 TIME_WAIT:为什么需要等2MSL?
四次挥手完成后,主动关闭方会进入TIME_WAIT状态,持续2MSL(Maximum Segment Lifetime,最大报文段生存时间)。MSL在Linux上默认是30秒,所以TIME_WAIT通常持续60秒。
为什么要等这么久?两个原因:
- 确保最后一个ACK能到达对方:第四次挥手的ACK可能丢失。如果对方没收到ACK,会重发FIN。如果主动关闭方直接关闭了,收到重发的FIN后无法回复ACK,对方就会一直卡在
LAST_ACK状态。 - 防止旧连接的数据干扰新连接:网络中的延迟数据包可能在新连接建立后到达。TIME_WAIT确保所有旧数据包在网络中消失,不会污染新连接。
我记得有一次,一个同事问我:“为什么我的服务端程序重启后,老是报Address already in use?”我一看,原来是上一个进程的TIME_WAIT还没结束,新进程想绑定同一个端口,就被拒绝了。
4.4 TIME_WAIT的危害:高并发场景下的“杀手”
TIME_WAIT虽然重要,但在高并发场景下,它可能成为性能瓶颈。我遇到过最夸张的一次,一台服务器上有6万多个TIME_WAIT连接,占满了所有可用端口。
TIME_WAIT的主要危害:
| 危害 | 说明 | 影响 |
|---|---|---|
| 端口耗尽 | 每个TIME_WAIT占用一个四元组(源IP、源端口、目标IP、目标端口),大量TIME_WAIT会耗尽可用端口 | 新连接无法建立 |
| 内存占用 | 每个TIME_WAIT连接在内核中占用约0.5KB内存,6万个就是30MB | 内存浪费 |
| 连接延迟 | 客户端频繁创建短连接时,每次都要等60秒才能复用端口 | 连接建立变慢 |
避坑指南:我曾经在一个高并发Web服务中,客户端用短连接频繁请求服务端。结果服务端出现大量TIME_WAIT,最终导致Cannot assign requested address错误。解决方案是改用长连接,或者调整内核参数net.ipv4.tcp_tw_reuse和net.ipv4.tcp_tw_recycle(注意:tcp_tw_recycle在Linux 4.12之后已被移除)。
4.5 端口复用:SO_REUSEADDR的妙用
端口复用选项SO_REUSEADDR,说白了就是告诉内核:“我知道这个端口可能还在TIME_WAIT状态,但我就是要用,你让我用吧。”
这个选项在以下场景特别有用:
- 服务端重启:服务端进程崩溃后快速重启,绑定同一个端口。没有
SO_REUSEADDR,你会看到Address already in use。 - 多进程监听同一端口:比如Nginx的worker进程,多个进程可以同时监听80端口。
代码示例(C语言):
int sock = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
// 设置端口复用
if (setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
perror("setsockopt");
exit(1);
}
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sock, (struct sockaddr*)&addr, sizeof(addr));
listen(sock, 10);
嗯,这里要注意:SO_REUSEADDR只对主动关闭方的TIME_WAIT有效。如果是被动关闭方,TIME_WAIT状态根本不会出现,所以不需要这个选项。
我建议:所有服务端程序在bind之前,都加上SO_REUSEADDR。这几乎成了我的编码习惯。你想想看,万一服务崩溃了,重启时因为端口被占用而失败,那多尴尬?
4.6 实战中的选择:什么时候该担心TIME_WAIT?
不是所有场景都需要担心TIME_WAIT。我总结了几条经验:
- 服务端主动关闭连接:服务端会进入TIME_WAIT。如果服务端是短连接模式(比如HTTP/1.0),TIME_WAIT会堆积。建议用长连接或调整内核参数。
- 客户端主动关闭连接:客户端进入TIME_WAIT。如果客户端频繁创建连接,端口会耗尽。建议用连接池。
- 使用SO_LINGER:设置
SO_LINGER为0可以跳过TIME_WAIT,但可能导致数据丢失。我个人不推荐在生产环境使用。
最后说一句:TIME_WAIT不是bug,是TCP协议设计者的智慧。它牺牲了一点性能,换来了可靠性。理解了这一点,你就能在性能和可靠性之间做出合理的选择。
总结:四次挥手是TCP断开连接的完整流程,TIME_WAIT是保证可靠性的关键机制。端口复用选项SO_REUSEADDR是解决TIME_WAIT导致端口占用问题的实用工具。理解这些,你就能写出更健壮的网络程序。