5、TCP状态与异常处理:TCP状态转移图、TIME_WAIT状态详解、半关闭状态、连接超时处理、心跳机制

TCP 协议,说白了就是一台机器和另一台机器之间,通过一条「虚拟的管道」在聊天。但这条管道不是一直通畅的,它有生命周期——从建立、传输、到关闭。每个阶段都有对应的状态。我刚开始写网络程序时,总觉得状态机是理论课才需要背的东西,直到线上服务出了故障,抓包一看,一堆连接卡在某个状态不动了……嗯,从那以后,我老老实实把这张图刻在了脑子里。

5.1 TCP 状态转移图

TCP 的状态转移,可以看作一个有限状态机。客户端和服务器各自维护一个状态变量,随着报文段的收发而迁移。下面这张图,是我每次排查连接问题时必看的「地图」。

TCP 状态转移图(核心路径) CLOSED SYN_SENT ESTABLISHED FIN_WAIT_1 TIME_WAIT LISTEN SYN_RCVD ESTABLISHED CLOSE_WAIT LAST_ACK connect() 收到 SYN+ACK close() 收到 ACK 收到 SYN 收到 ACK 收到 FIN 收到 ACK SYN SYN+ACK ACK FIN ACK

你想想看,一个连接从 CLOSED 开始,客户端主动调用 connect() 进入 SYN_SENT,服务器从 LISTEN 收到 SYN 后进入 SYN_RCVD。双方握手完毕,进入 ESTABLISHED——这才是我们真正收发数据的阶段。

关闭连接时,状态变化更复杂。主动关闭方会走 FIN_WAIT_1 → FIN_WAIT_2 → TIME_WAIT → CLOSED。被动关闭方则是 CLOSE_WAIT → LAST_ACK → CLOSED。这里最容易出问题的,就是 TIME_WAIT。

5.2 TIME_WAIT 状态详解

TIME_WAIT 是 TCP 协议里最「纠结」的一个状态。它持续 2MSL(Maximum Segment Lifetime)时间,通常为 1 到 4 分钟。为什么需要这么久?

核心原因有两个:

  • 保证最后一个 ACK 能被对方收到。如果 ACK 丢了,对方会重发 FIN,TIME_WAIT 状态还能响应这个重传。
  • 防止旧连接的报文干扰新连接。2MSL 足够让网络中所有属于旧连接的报文都消失。

我在项目中遇到过一个问题:高并发短连接场景下,服务器上积累了成千上万个 TIME_WAIT 状态的连接。端口被占满,新连接无法建立。当时我查了 netstat,发现 TIME_WAIT 数量高达 3 万多个。

我的处理方式:

  • 如果是客户端,可以复用端口(SO_REUSEADDR)。
  • 如果是服务器,考虑使用长连接代替短连接,减少连接创建销毁的频率。
  • 调整内核参数 net.ipv4.tcp_tw_reusenet.ipv4.tcp_tw_recycle(注意:tcp_tw_recycle 在 NAT 环境下有坑,我建议慎用)。

5.3 半关闭状态

TCP 支持半关闭——也就是说,一方可以停止发送数据,但仍然可以接收数据。这通过调用 shutdown() 实现,而不是 close()

// 半关闭示例:客户端停止发送,但仍可接收
int ret = shutdown(sockfd, SHUT_WR);
if (ret == -1) {
    perror("shutdown failed");
}

为什么需要半关闭?举个例子:HTTP/1.0 时代,服务器发完响应后直接 close(),客户端没法知道数据是否完整。半关闭允许服务器发送 FIN 表示「我说完了」,但客户端还能继续发请求(虽然 HTTP 协议不这么用)。

我个人习惯在自定义协议中使用半关闭来实现「单向流结束」的语义。比如文件上传场景:客户端发完文件内容后 shutdown(SHUT_WR),服务器知道数据发完了,可以开始处理,同时还能向客户端发送进度信息。

注意:半关闭后,不能再调用 write() 发送数据,否则会收到 SIGPIPE 信号或 EPIPE 错误。我曾经在代码里忘了这个限制,结果服务端在 shutdown 之后又尝试写数据,进程直接挂了。排查了半天才发现是信号处理的问题。

5.4 连接超时处理

网络是不可靠的。连接可能因为网络故障、对端崩溃等原因一直无法建立或响应。TCP 提供了超时机制,但默认超时时间往往太长(比如 connect 超时可能长达 127 秒)。

我建议在应用层主动设置超时,而不是依赖内核默认值。下面是一个非阻塞 connect 加超时的实现思路:

int connect_with_timeout(int sockfd, const struct sockaddr *addr, 
                         socklen_t addrlen, int timeout_ms) {
    // 设置为非阻塞
    int flags = fcntl(sockfd, F_GETFL, 0);
    fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);

    int ret = connect(sockfd, addr, addrlen);
    if (ret == 0) {
        // 连接立即成功(本地回环常见)
        fcntl(sockfd, F_SETFL, flags);
        return 0;
    }

    if (errno != EINPROGRESS) {
        return -1;  // 真正的错误
    }

    // 用 poll/epoll 等待连接完成
    struct pollfd pfd;
    pfd.fd = sockfd;
    pfd.events = POLLOUT;

    ret = poll(&pfd, 1, timeout_ms);
    if (ret <= 0) {
        return -1;  // 超时或出错
    }

    // 检查连接是否成功
    int error = 0;
    socklen_t len = sizeof(error);
    getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len);
    if (error != 0) {
        errno = error;
        return -1;
    }

    // 恢复阻塞模式
    fcntl(sockfd, F_SETFL, flags);
    return 0;
}

你想想看,如果不做超时控制,一个 connect 卡住 2 分钟,你的服务器可能已经堆积了成千上万个等待中的线程或协程。我见过一个案例,就是因为默认 connect 超时太长,导致服务器线程池被耗尽,最终拒绝服务。

5.5 心跳机制

TCP 的 keepalive 机制默认是关闭的,即使开启,默认间隔也是 2 小时。对于大多数实时性要求高的服务来说,这太慢了。

我个人习惯在应用层实现心跳。说白了,就是双方定期互发一个「我还活着」的消息。如果连续几次没收到心跳,就认为连接已断开。

// 心跳检测的简单实现思路
struct HeartbeatPacket {
    uint32_t magic;    // 魔数,用于识别心跳包
    uint32_t seq;      // 序列号,用于检测丢包
    uint64_t timestamp; // 时间戳
};

// 接收方:如果超过 N 秒没收到任何数据(包括心跳),判定超时
// 发送方:每隔 M 秒发送一个心跳包

心跳参数建议:

场景心跳间隔超时判定次数
内网服务3-5 秒3 次
公网服务10-30 秒3-5 次
移动网络30-60 秒2-3 次

我曾经在一个物联网项目中,设备通过 4G 网络连接服务器。网络环境很不稳定,经常出现「假连接」——设备已经断网了,但服务器端的 TCP 连接还处于 ESTABLISHED 状态。后来加了应用层心跳,30 秒没收到心跳就主动断开,问题迎刃而解。

避坑指南:心跳包不要太大,否则浪费带宽。我一般用 8-16 字节的固定格式。另外,心跳和业务数据可以共用同一个接收缓冲区,不要单独开线程处理心跳,否则容易引入竞态条件。

好了,TCP 状态和异常处理就聊到这里。这些知识点,说白了就是让你在面对网络问题时,能快速定位是哪个环节出了岔子。下次线上出现连接异常,别慌,先看看 netstat 里的状态,再对照这张状态转移图,问题往往就清晰了。


公众号:蓝海资料掘金营,微信deep3321