31、TCP状态转换:TCP状态机、三次握手状态变化、四次挥手状态变化

TCP 的状态转换,说白了就是一条连接从生到死的完整生命周期。我刚开始学网络编程时,总觉得这些状态是纸上谈兵,直到有一次线上服务出现大量 CLOSE_WAIT 连接,排查了一整天才发现是代码里少调了一次 close()。嗯,从那以后我再也不敢小看这张状态图了。

今天咱们就把 TCP 状态机掰开揉碎,重点看三次握手和四次挥手过程中,客户端和服务端各自经历了哪些状态变化。搞懂了这些,你写网络程序时心里就有底了。

TCP 状态机全景图

先上一张我手绘的状态转换图。别被这么多状态吓到,其实核心就 11 个状态,大部分时间我们只关心握手和挥手那几条路径。

TCP 状态转换核心路径 CLOSED SYN_SENT ESTABLISHED FIN_WAIT_1 FIN_WAIT_2 CLOSED LISTEN ESTABLISHED CLOSE_WAIT LAST_ACK 主动打开 收到 SYN+ACK 被动打开 收到 SYN,发 SYN+ACK ① SYN ② SYN+ACK ③ ACK ① FIN ② ACK ③ FIN ④ ACK 收到 ACK 收到 FIN 应用层 close()

核心要点: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 是「全双工」的,每一方向都要单独关闭。说白了就是:

  1. 第一次挥手(FIN):主动关闭方说「我不发了」—— 进入 FIN_WAIT_1
  2. 第二次挥手(ACK):被动关闭方说「知道了」—— 进入 CLOSE_WAIT,主动方收到后进入 FIN_WAIT_2
  3. 第三次挥手(FIN):被动关闭方说「我也不发了」—— 进入 LAST_ACK
  4. 第四次挥手(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_reusetcp_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