☰
跨平台Socket连接判活:半开连接检测与状态机封装
2026/10/27 12:12:33 网站建设 项目流程

长连接服务跑到线上之后,最容易被忽略的一类故障,是"本地以为连接还在、对端其实早就没了"。这类问题的表现形式很统一:日志里没有任何报错,写操作也返回成功,直到某一刻内核缓冲区被写满,才一次性爆出一堆错误。想在 Linux 和 Windows 上准确判断 socket 连接状态,靠单一的getsockopt(SO_ERROR)是远远不够的,必须把内核状态、事件通知、读写探测这三层信息叠起来看。这篇内容把两个平台上能用的判定手段挨个拆开,讲清楚每种方法在什么场景下可信、在什么场景下会骗人,最后给出一个可以跨平台收敛的状态机封装思路,以及我在实际排查中踩过的三次误判。适合正在写长连接网关、设备接入层、或者被"假死连接"折磨过的后端和客户端同学参考。

1. "连接还活着吗"这个问题,比新手想象的难得多

1.1 半开连接:对端已经消失,本地却一无所知

TCP 是一个两端各自维护状态的协议,中间还夹着路由器和各种网络设备。当对端进程崩溃、机器断电、或者中间的 NAT 设备把表项提前回收时,本地内核完全收不到任何通知。它依然认为这条连接处于ESTABLISHED状态,发送缓冲区里的数据照样排队,用户态的send()照样返回成功。这种情况在业内叫半开连接(half-open connection),是长连接系统里最典型的隐形故障。

真正让本地内核发现问题的触发点,通常只有两种:一是对端所在的网络设备回了一个 RST,比如对端进程重启后收到旧连接的报文,会回 RST;二是本地持续发送数据,触发了 TCP 的重传超时。而重传超时在默认内核参数下可以长达十几分钟,对业务来说显然不能接受。

所以判断连接状态这件事,本质上不是"查一个状态位",而是"用尽可能小的代价,尽早发现这条连接已经不可用"。代价和及时性是一对天然矛盾,后面的所有方案选择,都是在这对矛盾里找平衡点。

1.2 四种判定手段的精度与代价分层

把可用的手段按代价从低到高排列,大致是这样的:

层级手段代价能发现什么主要局限
L1 被动epoll / select 事件通知几乎为零RST、对端 FIN、本地错误静默断网时毫无反应
L2 轻量SO_ERROR + MSG_PEEK 探测一次系统调用挂起错误、对端有序关闭半开连接仍可能返回"正常"
L3 深入TCP_INFO / SIO_TCP_INFO一次系统调用,需平台支持内核眼里的完整状态机结构体版本差异,需处理兼容
L4 主动应用层心跳一个往返 RTT端到端真实可达性有流量开销,需要协议配合

实际工程里的正确做法不是四选一,而是把它们当成一个漏斗:L1 免费跑着,L2 在需要的时候调用,L3 用于疑难排查和监控采样,L4 作为兜底保证业务语义上的"活着"。很多实现只做了 L1 或者只做了 L2,然后就以为问题解决了,结果在半开连接上栽跟头。

还有一个容易被忽略的前提:判断连接状态的时机很重要。连接刚建立、还没有任何数据交互的时候,SO_ERROR返回 0 是正常的也是真实的;但一条空闲了两小时的长连接,SO_ERROR返回 0 几乎说明不了任何问题。同样的返回值,在不同生命周期阶段的可信度完全不同。

2. Linux 侧的判定链路:SO_ERROR、MSG_PEEK 与 TCP_INFO

2.1 SO_ERROR 的真实语义与误用重灾区

SO_ERROR是我见过被误用最多的一个选项。它的实际语义是"取出并清除这个 socket 上挂起的待处理错误",而不是"查询连接是否健康"。看它的实现就能明白:内核把异步发生的错误(比如收到 RST、连接超时)暂存在 socket 的错误字段里,等用户态来取。取一次就清零了。

static int take_socket_error(int fd) { int err = 0; socklen_t len = sizeof(err); if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) < 0) { return -errno; /* getsockopt 自身失败,多半是 fd 已经无效 */ } return err; /* 0 仅仅表示"此刻没有挂起的错误" */ }

这里的坑有两个。第一,返回值 0 被当成了"连接可用"的充分条件,实际上它只是必要条件——半开连接在本地没有发出任何数据之前,挂起错误字段就是 0。第二,这个调用是破坏性的,读一次就清零。如果在日志埋点里随手调了一次,真正的业务读路径就再也看不到这个错误了,排查时会非常困惑。我一般要求团队里只有一处代码允许调用它,并且调用结果必须落日志。

正确的用法是把SO_ERROR当成"事件触发的快照":只有在 epoll 报了EPOLLERR,或者send返回了非EAGAIN的错误,或者心跳超时之后,才去读它,用来确定具体是哪种错误。把它当轮询用的健康检查接口,是南辕北辙。

2.2 MSG_PEEK 组合拳与 errno 的正确读法

recv配MSG_PEEK是 Linux 上最实用的轻量探测手段。它的好处是不消费数据——探测到的字节还留在内核接收缓冲区里,正儿八经的业务读还能读到。加上MSG_DONTWAIT保证不阻塞,一次调用就能拿到三种结论。

enum { PROBE_DATA = 1, PROBE_IDLE = 0, PROBE_EOF = -1, PROBE_DEAD = -2 }; static int probe_peek(int fd) { char c; ssize_t n = recv(fd, &c, 1, MSG_PEEK | MSG_DONTWAIT); if (n > 0) return PROBE_DATA; /* 有数据待读,连接至少单向可用 */ if (n == 0) return PROBE_EOF; /* 收到对端 FIN,有序关闭 */ switch (errno) { case EAGAIN: #if EWOULDBLOCK != EAGAIN case EWOULDBLOCK: #endif case EINTR: return PROBE_IDLE; /* 无数据,连接未恶化 */ default: return PROBE_DEAD; /* ECONNRESET / ENOTCONN / EBADF 等 */ } }

返回值必须严格区分这三种情况,混在一起就会出大问题。n == 0和n == -1 && errno == EAGAIN的语义天差地别:前者是对端明确发了 FIN,属于有序关闭,本地还能把缓冲区里剩下的数据读完再关;后者只是"现在没数据",连接完全健康。我见过把两者都当成"连接不健康"直接关闭的实现,导致空闲连接被周期性误杀,表现为业务侧莫名其妙的断连重连。

还有一个细节:recv返回 0 之后,这条连接虽然读方向结束了,但写方向在理论上可能还有效(TCP 半关闭)。如果你的协议是请求-响应模型,收到 FIN 通常意味着对端不会再发请求了,此时应该优雅收尾而不是立刻close,否则可能丢掉已经在路上的响应。这个取舍要看具体协议,不能一刀切。

2.3 用 TCP_INFO 拿到内核眼里的一手连接状态

TCP_INFO是 Linux 独有的一个选项(BSD 系也有类似实现),它返回一个struct tcp_info,里面是内核 TCP 状态机的完整快照。要判断连接状态,第一眼应该看tcpi_state。

#include <netinet/tcp.h> static int read_tcp_info(int fd, struct tcp_info *out) { socklen_t len = sizeof(*out); memset(out, 0, sizeof(*out)); if (getsockopt(fd, IPPROTO_TCP, TCP_INFO, out, &len) < 0) { return -errno; } /* len 会被内核改写成实际写入的长度,可能小于 sizeof(struct tcp_info) */ return (int)len; }

关键点在于len这个入参出参混合变量。内核支持的结构体长度会随版本变化,老内核只会填充前面一部分字段。如果调用方硬编码sizeof(struct tcp_info)去判断,遇到老内核会得到EINVAL。正确姿势是入参传自己编译期看到的长度,出参用内核返回的长度判断哪些字段有效,这也是所有"变长结构体"系统调用的通用约定。

tcpi_state的取值和ss命令看到的完全一致:1 是ESTABLISHED,8 是CLOSE_WAIT,7 是CLOSE。除了状态本身,还有几个字段非常有诊断价值:

  • tcpi_total_retrans:累计重传次数。一条连接如果这个数字在持续增长,说明链路质量有问题,即使状态还是ESTABLISHED也不能算健康。
  • tcpi_unacked:已发出但未被确认的段数。这个数字一直不归零,是典型的"发得出去收不到 ACK"。
  • tcpi_probes:保活探测已发送的次数。大于 0 说明 keepalive 已经在工作了,这是半开连接的早期信号。
  • tcpi_rtt:平滑后的 RTT,单位是微秒。这个值突然暴涨往往预示着链路拥塞或即将断开。

我的经验是把tcpi_total_retrans和tcpi_unacked采样进监控,做成一条曲线。单看某一时刻的状态码意义不大,看趋势能提前十几分钟发现异常链路,比事后救火强太多。

2.4 epoll 事件里的 EPOLLRDHUP 与 EPOLLHUP 区别

EPOLLRDHUP和EPOLLHUP这两个事件被混淆的概率极高。前者表示对端关闭了写方向(发了 FIN),是半关闭;后者表示本地这个 fd 的读写两个方向都挂断了。前者可以继续写,后者写也没用了。

要收到EPOLLRDHUP,有两个前提:编译时定义_GNU_SOURCE,注册时显式写上这个标志位。很多人只在注册时写了EPOLLIN,然后纳闷为什么对端关了却收不到通知。

struct epoll_event ev; ev.events = EPOLLIN | EPOLLRDHUP | EPOLLERR; ev.data.fd = fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev) < 0) { /* 处理失败 */ }

EPOLLHUP和EPOLLERR属于内核强制上报的事件,即使没在events里写也会收到,这一点和EPOLLRDHUP不同。收到EPOLLERR之后必须调用一次getsockopt(SO_ERROR)把错误取出来,否则事件会一直重复上报,形成忙循环——这是一个非常隐蔽的性能坑,表现为某个核的 CPU 莫名跑满。

还有一个容易踩的组合:EPOLLET边缘触发模式下,收到EPOLLRDHUP之后必须把缓冲区里的数据读完,否则不会再收到通知。边缘触发的规则是"状态变化才通知",不会因为你没读完就反复提醒。

2.5 /proc/net/tcp 兜底排查(线下调试用)

线上不方便加日志的时候,/proc/net/tcp是个很好的旁路验证手段。它的st字段是十六进制的 TCP 状态码,01是ESTABLISHED,08是CLOSE_WAIT。地址和端口是十六进制、并且按主机字节序排列的小端表示,读的时候要转换。

我通常用它来做两件事:一是核对某个进程持有的连接数和状态分布,二是验证代码里的状态判断逻辑是否准确。方式是同时打印出 fd 和/proc/net/tcp里对应条目的状态,两边对比。如果代码说ESTABLISHED,而/proc里显示CLOSE_WAIT,那百分之百是状态判断写错了。

注意这只是调试手段,不能放到生产代码的判定逻辑里。一是解析开销大,二是它反映的是整个系统视角的快照,中间有时间窗口,不适合做实时决策。只在排查疑难问题时用,用完就删。

3. Windows 侧的判定链路:从 select 到 SIO_TCP_INFO

3.1 select / WSAPoll 的返回值怎么读才不出错

Windows 上没有 epoll,主流的等待机制是select、WSAEventSelect和WSAPoll。判断连接状态时,select的用法和 Linux 类似但有几个专属的坑。

#define WIN32_LEAN_AND_MEAN #include <winsock2.h> #include <ws2tcpip.h> static int probe_select(SOCKET s) { fd_set rfds, wfds, efds; FD_ZERO(&rfds); FD_ZERO(&wfds); FD_ZERO(&efds); FD_SET(s, &rfds); FD_SET(s, &wfds); FD_SET(s, &efds); struct timeval tv = { 0, 0 }; /* 零超时 = 非阻塞轮询 */ int r = select(0, &rfds, &wfds, &efds, &tv); if (r == SOCKET_ERROR) return -2; if (r == 0) return 0; /* 什么都没发生 */ if (FD_ISSET(s, &efds)) return -2; /* 带外数据/异常 */ if (FD_ISSET(s, &rfds)) { char c; int n = recv(s, &c, 1, MSG_PEEK); if (n == 0) return -1; /* 对端有序关闭 */ if (n == SOCKET_ERROR) { int e = WSAGetLastError(); if (e == WSAEWOULDBLOCK) return 0; return -2; } return 1; /* 有数据 */ } return 0; }

最重要的一个认知:一条已建立的连接几乎永远是"可写"的,因为发送缓冲区有空间。所以只看wfds毫无意义。但反过来,如果连接已经因为 RST 挂掉了,它也会变成"可写",同时recv会立刻返回WSAECONNRESET。这就意味着"可写"这个信号既能表示健康也能表示死亡,必须配合recv才能定性。

关于WSAPoll,我建议在需要兼容老系统的项目里谨慎使用。它有一个众所周知的行为差异:对端优雅关闭时,某些系统版本上不一定会置POLLHUP,导致依赖POLLHUP判断断连的代码漏判。稳妥的做法是只要POLLRDNORM被置位,就调一次recv(MSG_PEEK)去看返回值,不要指望事件标志本身告诉你一切。

3.2 WSAIoctl + SIO_TCP_INFO 拿内核态连接信息

从 Windows 10 1703 开始,系统提供了SIO_TCP_INFO控制码,功能对应 Linux 的TCP_INFO。这是 Windows 上唯一能拿到内核 TCP 状态机的正规途径,比看事件标志精确得多。

#include <mstcpip.h> static BOOL query_tcp_info(SOCKET s, TCP_INFO_v0 *out) { DWORD version = 0; /* 0 表示请求 TCP_INFO_v0 */ DWORD bytes = 0; memset(out, 0, sizeof(*out)); int rc = WSAIoctl(s, SIO_TCP_INFO, &version, sizeof(version), out, sizeof(*out), &bytes, NULL, NULL); return rc == 0; }

返回结构里的State字段取值和MIB_TCP_STATE_*常量一致,MIB_TCP_STATE_ESTAB = 5。结构里还有BytesOut、BytesIn、SegsOut、SegsIn、Rtt等字段,其中Rtt单位是微秒,可以直接和监控系统对齐。

兼容性必须处理:在 Windows 7 或者 Windows 8 上,WSAIoctl会返回WSAEINVAL。这不是程序的 bug,是系统不支持。封装层要能识别这个错误码并降级到select+recv(MSG_PEEK)的方案,而不是直接报错退出。我见过在旧系统上直接崩掉的实现,就是因为没做这个降级。

另外要注意mstcpip.h的包含顺序。它必须在winsock2.h之后包含,并且项目要链接Ws2_32.lib。如果先包含了windows.h,会引入winsock.h造成一堆重定义错误,处理办法是在最前面定义WIN32_LEAN_AND_MEAN。

3.3 保活参数在 Windows 上的两套设置方式

Windows 的 TCP keepalive 有两套配置接口,用途不同,混用会互相覆盖。

第一套是标准setsockopt:

BOOL on = TRUE; setsockopt(s, SOL_SOCKET, SO_KEEPALIVE, (const char *)&on, sizeof(on));

这套只负责开关,间隔参数走注册表全局配置,改不了单条连接。

第二套是WSAIoctl的SIO_KEEPALIVE_VALS,能针对单条连接设置探测间隔和重试间隔,单位都是毫秒:

tcp_keepalive ka; ka.onoff = 1; ka.keepalivetime = 30000; /* 空闲 30 秒后开始探测 */ ka.keepaliveinterval = 5000; /* 探测间隔 5 秒 */ DWORD ret = 0; WSAIoctl(s, SIO_KEEPALIVE_VALS, &ka, sizeof(ka), NULL, 0, &ret, NULL, NULL);

注意这套接口没有重试次数参数,Windows 默认探测 10 次失败后关闭连接。也就是说上面这个配置下,最坏情况下 30 + 5 × 10 = 80 秒才能发现断连。这个数字在很多业务场景里依然偏长,所以它只能作为兜底,不能替代应用层心跳。

3.4 GetExtendedTcpTable 的离线核对法

Windows 上对应/proc/net/tcp的角色是GetExtendedTcpTable。它能枚举系统上所有 TCP 连接及其状态,并关联到持有该连接的进程 PID。排查"到底是谁占着这个端口不放"这类问题时,这个接口非常管用。

#include <iphlpapi.h> #pragma comment(lib, "Iphlpapi.lib") DWORD size = 0; GetExtendedTcpTable(NULL, &size, FALSE, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0); PMIB_TCPTABLE_OWNER_PID table = (PMIB_TCPTABLE_OWNER_PID)malloc(size); if (table && GetExtendedTcpTable(table, &size, FALSE, AF_INET, TCP_TABLE_OWNER_PID_ALL, 0) == NO_ERROR) { for (DWORD i = 0; i < table->dwNumEntries; ++i) { MIB_TCPROW_OWNER_PID *row = &table->table[i]; USHORT lport = ntohs((u_short)row->dwLocalPort); /* row->dwState 与 MIB_TCP_STATE_* 对应 */ } } free(table);

两个细节必须记住:第一次调用传NULL是为了拿缓冲区大小,这个调用一定会返回ERROR_INSUFFICIENT_BUFFER,属于正常流程,不要当成错误;dwLocalPort和dwRemotePort是把网络字节序的端口塞进了一个 32 位整数里,取值时要先截断成 16 位再做ntohs。

这套接口只用来做诊断和观测,不要放进高频判活的路径里。枚举全表是 O(n) 的开销,连接数上万的时候足以拖垮性能。

4. 把两套实现收敛成一个跨平台状态机

4.1 状态枚举与三档探测策略

两个平台的机制差异很大,但对外暴露的状态语义是可以统一的。我一般定义这样一个枚举:

typedef enum { CONN_STATE_UNKNOWN = 0, CONN_STATE_ALIVE, /* 状态为 ESTABLISHED 且无挂起错误 */ CONN_STATE_IDLE, /* 无数据但连接未恶化 */ CONN_STATE_PEER_CLOSED, /* 对端有序关闭(收到 FIN) */ CONN_STATE_RESET, /* 收到 RST 或被主动中断 */ CONN_STATE_LOCAL_ERROR /* fd 无效、参数错误等本地问题 */ } conn_state_t;

ALIVE和IDLE必须分开。前者意味着"我也许刚收到过数据或者内核状态健康",后者意味着"暂时没数据"。很多业务逻辑在IDLE状态下应该继续等待,在PEER_CLOSED状态下才该收尾。合并成"可用/不可用"二值会让状态机丧失所有的优雅处理能力。

三档探测的实现大致是这样的:

conn_state_t conn_check(int fd) { /* 第一档:取挂起错误,非 0 直接定性 */ int err = take_socket_error(fd); if (err == ECONNRESET) return CONN_STATE_RESET; if (err == ECONNABORTED) return CONN_STATE_RESET; if (err != 0) return CONN_STATE_LOCAL_ERROR; /* 第二档:peek 探测,看是否有 FIN 或异常 */ switch (probe_peek(fd)) { case PROBE_EOF: return CONN_STATE_PEER_CLOSED; case PROBE_DEAD: return CONN_STATE_RESET; case PROBE_DATA: return CONN_STATE_ALIVE; default: break; } /* 第三档:读取内核状态(受平台条件编译控制) */ conn_state_t deep = probe_deep(fd); if (deep != CONN_STATE_UNKNOWN) return deep; return CONN_STATE_IDLE; }

这个顺序的用意很清楚:SO_ERROR最快也最安全,但它是破坏性读取,所以只在需要的时候调用;MSG_PEEK不消费数据,可以放心用;TCP_INFO会涉及结构体兼容性,放在最后兜底。三档全部返回"正常"时给出IDLE,而不是ALIVE——因为此时确实没有任何证据表明对端还在。

4.2 接口封装与错误码归一化

跨平台封装最容易失控的地方是错误码。Linux 是errno加负值返回,Windows 是WSAGetLastError()加SOCKET_ERROR常量,而且同名错误的数值完全不同。ECONNRESET在 Linux 上是 104,在 Windows 上对应的是WSAECONNRESET,值是 10054。

我的做法是定义一层内部的错误码映射表,把两个平台的相关错误统一到一个枚举上:

内部错误码Linux errnoWindows WSA 码语义
ERR_NET_AGAINEAGAIN / EWOULDBLOCKWSAEWOULDBLOCK暂时无数据,重试即可
ERR_NET_RESETECONNRESETWSAECONNRESET对端重置连接
ERR_NET_ABORTEDECONNABORTEDWSAECONNABORTED连接被本地中止
ERR_NET_NOTCONNENOTCONNWSAENOTCONNsocket 未连接
ERR_NET_INTREINTRWSAEINTR被信号/中断打断
ERR_NET_BADFDEBADFWSAENOTSOCK句柄无效

这层映射看起来啰嗦,但能省掉后面无数个#ifdef。所有业务代码只认内部码,平台差异全部收敛在一个.c文件里。实测下来,凡是把这个映射做扎实的项目,后面加第三个平台(比如某个嵌入式 RTOS)的成本会低一个数量级。

封装接口本身建议只暴露三个函数:conn_check、conn_close_graceful、conn_close_abort。判断和关闭要分开,因为有序关闭需要先shutdown(SHUT_WR)再等对端确认,而异常关闭直接closesocket就行。这两条路径混在一起,是"服务重启时丢消息"的高发地带。

4.3 长连接场景下的探测频率设计

探测频率是个纯工程问题,没有标准答案,但有明确的约束边界。

太频繁的代价很直接:每次conn_check至少一次系统调用,万级连接、每秒一次就是万级 syscall,上下文切换的开销肉眼可见。而且SO_ERROR是破坏性读取,调用太多会在日志里制造大量噪声。

太稀疏的代价更直接:断连发现延迟变大。假设你的业务要求在 30 秒内感知设备离线,那么探测周期就不能长于 15 秒,否则最坏情况会超出预算。

我的经验配置是分档的:活跃连接(最近 60 秒内有收发)不做额外探测,靠 epoll/select 事件即可;空闲连接(60 秒到 10 分钟)每 30 秒做一次probe_peek;超长空闲连接(10 分钟以上)每 30 秒做一次probe_peek,并每 5 分钟采样一次TCP_INFO记录重传和未确认段数。

还有一个常被忽略的点:探测本身要有熔断。如果某条连接连续几次探测都返回IDLE,但应用层心跳一直没回,那就不要再探测了,直接判定死亡。继续探测只会浪费资源,形成"僵尸连接池"。

5. 三次误判的完整排查链路复盘

5.1 第一次:SO_ERROR 返回 0 却被当成"连接正常"

现象是一个设备接入服务,运维反馈有 30% 左右的设备在平台上显示"在线",但下发指令全部超时。日志里看不出任何异常,SO_ERROR全部返回 0。

排查链路是这样的。第一步,先确认是不是应用层的问题——抓包看有没有发出指令。抓包结果显示指令确实发出去了,但没有任何 ACK 回来。第二步,怀疑是网络中间设备丢包,于是让对端配合抓包,结果对端根本没收到。第三步,把抓包位置挪到本机网卡出口,发现有PSH包出去,也有重传,但一直没有回应。到这一步基本确认是半开连接了。

根因很清晰:设备侧运行在某些移动网络下,运营商侧的表项回收周期通常只有几分钟到十几分钟,回收之后设备侧早已断开,而服务端内核还保留着ESTABLISHED。SO_ERROR之所以返回 0,是因为本地一直在"发但没收到 ACK",还没有触发重传超时,没有任何错误被写入到 socket 的错误字段。

修复方案改成了三层:应用层每 20 秒一次心跳,两轮没响应就判定死亡;probe_peek探测到EAGAIN只记录IDLE不做关闭决策;TCP_INFO的采样里把tcpi_unacked持续大于 0 作为一个告警信号。这套改完之后,断连发现延迟从最坏十几分钟压到了 40 秒以内。

这个坑的教训是:SO_ERROR == 0只能证明"内核还没意识到出事了",不能证明"对端还在"。两者之间差着一个重传周期。

5.2 第二次:EPOLLHUP 事件漏注册导致的假死

第二个案例更隐蔽。现象是某个长连接集群里的部分节点,内存持续增长,established状态的连接数只增不减,但业务量正常。

排查从连接数监控入手,发现CLOSE_WAIT状态的连接在缓慢累积。CLOSE_WAIT意味着对端发了 FIN,本地收到了,但应用层没有调用close。也就是说,对端关闭的通知到了,但代码没有响应。

去翻 epoll 的注册代码,发现问题了:注册时只写了EPOLLIN,没有写EPOLLRDHUP。而对端是优雅关闭(发 FIN),这种情况下内核上报的是EPOLLRDHUP而不是EPOLLERR。没有注册这个标志,就完全收不到通知,连接一直挂在CLOSE_WAIT里。

顺便还发现了一个衍生问题:某处epoll_ctl用的是EPOLL_CTL_MOD,把events从EPOLLIN | EPOLLRDHUP改成了EPOLLIN | EPOLLOUT,把EPOLLRDHUP弄丢了。这种"修改事件时忘记保留原标志"的写法非常常见,我的做法是把它封装成一个update_interest(fd, mask)函数,内部维护一个期望的掩码,任何修改都基于这个掩码做位运算,禁止直接赋值。

修复之后CLOSE_WAIT的累积立刻停止。这个案例说明:事件驱动的判活,前提是事件注册得对。注册错了,再精细的状态机也没用武之地。

5.3 第三次:Windows 上 WSAPoll 的 POLLHUP 陷阱

第三个案例出在客户端。某个 Windows 客户端程序,在服务端重启后会卡住——不重连、不报错、不退出,稳定复现。

排查时先加了日志,发现服务端重启后客户端确实收到了数据层面的"连接关闭",但代码里的断连分支没有被触发。去看代码,用的是WSAPoll,判断逻辑是if (revents & POLLHUP) { 断连处理(); }。

问题就在这里。对端优雅关闭(发 FIN)时,WSAPoll返回的事件标志在不同系统版本上的表现不一致,某些情况下只置POLLRDNORM而不置POLLHUP。代码死等POLLHUP,自然等不到。

修复方案很直接:只要POLLRDNORM或POLLERR被置位,就一律调一次recv(MSG_PEEK),用返回值定性。返回 0 是对端关闭,返回SOCKET_ERROR且WSAGetLastError() == WSAECONNRESET是重置,返回正数则是有数据。这样就不再依赖事件标志的语义了。

顺带还统一了另一件事:所有recv都在一个safe_recv函数里走,内部处理WSAEWOULDBLOCK、WSAEINTR、WSAECONNRESET三类错误,业务层只看归一化后的结果。改完之后,跨版本的不一致问题彻底消失了。

一个通用的经验:事件标志是不可靠的间接信号,recv的返回值才是一手的直接证据。能用后者定性的时候,不要依赖前者。

6. 应用层心跳:被动检测之外的主动确认

6.1 为什么 TCP keepalive 不能当业务心跳用

TCP keepalive 的默认参数在 Linux 上是 7200 秒空闲、75 秒间隔、9 次探测。也就是说,一条完全死掉的连接,默认配置下最坏需要 7200 + 75 × 9 = 7875 秒,接近 2 小时 11 分钟才能被内核关闭。这个时间对任何在线业务都是不可接受的。

虽然可以通过TCP_KEEPIDLE、TCP_KEEPINTVL、TCP_KEEPCNT调小,但即使调到 30 秒开始探测、5 秒间隔、3 次,最坏也要 45 秒,而且这还是内核层面的连通性,不代表应用层处理能力正常。如果对端进程已经卡死在死循环里,TCP 层照样能正常 ACK,keepalive 探测全部通过,但业务早就不可用了。

所以 keepalive 的定位应该是兜底清理,用来回收那些应用层心跳也没覆盖到的极端情况,而不是主要的判活手段。真正决定"对方还能不能干活"的,只能是应用层协议自己定义的心跳。

心跳设计有几个实践上的要点。一是心跳帧要足够小,最好只有几个字节的报文头,避免在万级连接上制造带宽压力。二是心跳频率要和业务容忍的断连感知时间挂钩,通常取容忍时间的三分之一到二分之一。三是心跳的判定要允许一定的抖动,比如"两个周期未响应"再判定死亡,而不是一次没回就断连,否则在网络抖动的场景下会大面积误杀。

6.2 心跳帧设计与会话摘除顺序

心跳帧最简单的形式就是一个固定长度的魔数加一个递增序号,序号用于匹配请求和响应,也用于检测乱序和重复。响应帧原样带回序号即可。

真正需要小心的是发现心跳超时之后的处理顺序。常见错误做法是直接closesocket,这会导致两个问题:一是在多线程环境下,另一个线程可能正在这条连接上读写,造成访问已释放内存;二是业务层可能还没来得及清理会话状态,形成泄漏。

我用的顺序是这样的:先把会话标记为DYING,让新的读写请求被拒绝;然后从 epoll/select 的监听集合里摘除;接着shutdown(SHUT_WR)发 FIN,给对端一个优雅关闭的机会;等待一个很短的宽限期(通常 3 到 5 秒);最后调用统一关闭函数,并通知业务层清理会话。

static void conn_reap(conn_t *c) { if (c->state == CONN_STATE_DYING) return; c->state = CONN_STATE_DYING; /* 先拒绝新的读写请求 */ poller_remove(c->fd); /* 从事件循环摘除 */ shutdown(c->fd, SHUT_WR); /* 发 FIN,等待对端响应 */ timer_add(c->reap_timer, 3000, conn_close_final, c); }

这个顺序的价值在实测里体会最深。有一次线上因为网络抖动触发了大规模心跳超时,几千条连接同时被摘除。用旧代码(直接close)的时候,业务侧收到了大量半截响应和内存告警;换成这套顺序之后,只是对端发现连接关闭了,业务数据完整,用户体验上就是"重连了一次"。

最后再提一个容易忽略的点:心跳的计时器不要用秒级精度去卡。实际操作系统的定时器精度、调度延迟、GC 停顿都会引入抖动,用毫秒级阈值去判定"超时"会非常不稳定。我一般把判定阈值设成心跳周期的两倍,并且用单调时钟(Linux 的CLOCK_MONOTONIC、Windows 的QueryPerformanceCounter)来计时,避免系统时间被调整时整个心跳体系跟着乱跳。这一点在做过时间同步的机器上尤其重要,用墙上时钟做心跳超时判定,迟早会挨一次莫名其妙的批量断连。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询