31、TCP状态转换:TCP状态机、三次握手状态变化、四次挥手状态变化
TCP 的状态转换,说白了就是一条连接从生到死的完整生命周期。我刚开始学网络编程时,总觉得这些状态是纸上谈兵,直到有一次线上服务出现大量 CLOSE_WAIT 连接,排查了一整天才发现是代码里少调了一次 close()。嗯,从那以后我再也不敢小看这张状态图了。
今天咱们就把 TCP 状态机掰开揉碎,重点看三次握手和四次挥手过程中,客户端和服务端各自经历了哪些状态变化。搞懂了这些,你写网络程序时心里就有底了。
TCP 状态机全景图
先上一张我手绘的状态转换图。别被这么多状态吓到,其实核心就 11 个状态,大部分时间我们只关心握手和挥手那几条路径。
核心要点:TCP 状态机一共 11 个状态,但日常开发中你只需要记住 6 个:CLOSED、LISTEN、SYN_SENT、ESTABLISHED、FIN_WAIT_1/2、CLOSE_WAIT。其他状态(如 TIME_WAIT)虽然重要,但属于「被动触发」的边界情况。
三次握手的状态变化
三次握手,说白了就是双方互相确认「我能发、你能收」的过程。我习惯把它类比成打电话:
- 第一次握手(SYN):客户端说「喂,能听到吗?」—— 客户端进入
SYN_SENT状态 - 第二次握手(SYN+ACK):服务端说「听到了,你能听到我吗?」—— 服务端从
LISTEN进入ESTABLISHED - 第三次握手(ACK):客户端说「我也听到了」—— 客户端从
SYN_SENT进入ESTABLISHED
你想想看,为什么是三次而不是两次?如果只有两次握手,服务端无法确认客户端是否收到了自己的 SYN+ACK。万一丢了,服务端会一直傻等,浪费资源。
个人经验:我在写高并发服务器时,经常用 netstat -ant | grep SYN_RECV 来排查半连接队列是否溢出。如果 SYN_RECV 数量持续增长,说明客户端发 SYN 但没完成握手——可能是 SYN 洪水攻击,也可能是服务端 backlog 设得太小。
四次挥手的状态变化
挥手比握手复杂一些,因为 TCP 是「全双工」的,每一方向都要单独关闭。说白了就是:
- 第一次挥手(FIN):主动关闭方说「我不发了」—— 进入
FIN_WAIT_1 - 第二次挥手(ACK):被动关闭方说「知道了」—— 进入
CLOSE_WAIT,主动方收到后进入FIN_WAIT_2 - 第三次挥手(FIN):被动关闭方说「我也不发了」—— 进入
LAST_ACK - 第四次挥手(ACK):主动关闭方说「好的」—— 进入
TIME_WAIT,等待 2MSL 后回到CLOSED
避坑指南:我曾经遇到过一个线上故障,服务端大量连接卡在 CLOSE_WAIT 状态。排查后发现是代码里只调了 shutdown(SHUT_RD),没有调 close()。记住:收到 FIN 后,应用层必须调用 close() 或 shutdown(SHUT_WR) 才能发出自己的 FIN,否则连接永远挂在 CLOSE_WAIT。
关键状态详解
| 状态 | 出现场景 | 常见问题 |
|---|---|---|
LISTEN |
服务端调用 listen() 后 | 端口被占用时无法进入 |
SYN_SENT |
客户端调用 connect() 后 | 连接超时可能卡在此状态 |
ESTABLISHED |
三次握手完成 | 正常通信状态 |
FIN_WAIT_1 |
主动关闭方发出 FIN | 如果对端不响应,会超时 |
FIN_WAIT_2 |
收到对端 ACK 后 | 对端不关闭时可能一直停留 |
CLOSE_WAIT |
被动关闭方收到 FIN | 应用层未 close() 导致泄漏 |
LAST_ACK |
被动方发出 FIN 后 | 等待最后一个 ACK |
TIME_WAIT |
主动方收到 FIN 后 | 2MSL 等待,端口被占用 |
TIME_WAIT 为什么是 2MSL?
这个问题面试常问。说白了就两个原因:
- 保证最后一个 ACK 能到达:如果 ACK 丢了,对端会重发 FIN,2MSL 足够让重发的 FIN 到达并重新响应
- 让旧连接的所有报文都消失:防止新连接收到旧连接的残留数据包
我记得有一次压测时,客户端端口被 TIME_WAIT 占满,导致无法建立新连接。解决方案是调整 /proc/sys/net/ipv4/tcp_tw_reuse 和 tcp_tw_recycle 参数。不过现在 Linux 4.12+ 已经废弃了 tcp_tw_recycle,建议用 SO_REUSEADDR 选项。
实战建议:写 TCP 服务端时,一定要在 bind() 之前设置 SO_REUSEADDR。否则服务端重启时,如果之前的 TIME_WAIT 还没消失,bind() 会失败。我刚开始写服务器时就被这个坑过,服务挂了重启不了,急得满头大汗。
用代码观察状态变化
光看理论不过瘾,咱们写个小程序来观察状态变化。下面这段代码模拟了客户端主动关闭的过程:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main() {
int sock = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
// 连接前:CLOSED
printf("连接前状态:CLOSED\n");
connect(sock, (struct sockaddr*)&addr, sizeof(addr));
// 连接后:ESTABLISHED
printf("连接后状态:ESTABLISHED\n");
// 主动关闭
close(sock);
// 关闭后:FIN_WAIT_1 -> FIN_WAIT_2 -> TIME_WAIT
printf("关闭后进入 TIME_WAIT(2MSL 后自动消失)\n");
return 0;
}
运行这段代码的同时,在另一个终端执行 netstat -ant | grep 8080,就能看到状态的变化过程。我个人习惯用 watch -n 0.5 'netstat -ant | grep 8080' 来实时监控。
总结
TCP 状态转换其实不复杂,记住几个关键点就行:
- 三次握手:CLOSED → SYN_SENT → ESTABLISHED(客户端),CLOSED → LISTEN → ESTABLISHED(服务端)
- 四次挥手:ESTABLISHED → FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED(主动方)
- ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED(被动方)
- TIME_WAIT 持续 2MSL,大约 1-4 分钟
嗯,搞懂了这些,你写网络程序时遇到连接异常、端口占用、CLOSE_WAIT 堆积等问题,就能快速定位了。下次咱们聊聊如何用 epoll 高效管理大量连接。
公众号:蓝海资料掘金营,微信deep3321