☰
Socket连接存活检测:KeepAlive与心跳跨平台实践
2026/10/27 3:34:59 网站建设 项目流程

1. 先把问题定义清楚:什么叫"socket 连接还活着"

写这块内容之前,我先说个真实场景。某项目做的是一个长连接推送服务,客户端遍布 Linux 服务器和 Windows 桌面端。上线两周后运维反馈:服务端 hold 住的连接数一直在涨,但实际活跃用户没变。抓包一看,一大半连接的对端早就掉线了,有的是客户端机器休眠,有的是中间网络设备把会话表项老化掉了,服务端这边socket还老老实实躺在ESTABLISHED状态里,应用层的逻辑还以为自己在跟人说话。这就是典型的"连接状态判断"没做对。

在 Linux 或 Windows 下判断 socket 连接状态,本质上要回答三个不同层次的问题:这个 fd 在我们进程里还算不算有效句柄;内核认为这条 TCP 连接处于什么状态;对端应用此刻是否真的还能收发包。这三件事的答案经常不一致,而绝大多数线上事故,就出在把第一个问题的答案当成了第三个问题的答案。这篇文章我会把常见的判断手段、各自的适用边界、Linux 和 Windows 的接口差异,以及我自己踩过的坑一次性讲清楚,适合正在写网络模块、连接池、网关或者长连接服务的同学参考,也适合排查"明明连着却发不出去"这类玄学问题的老手速查。

1.1 连接状态其实是四层,别混为一谈

我习惯把"连接是否存活"拆成四层来看,理解了这个分层,后面所有判断手段的取舍就顺理成章了。

第一层是文件描述符层。fd >= 0只说明这个整数在内核里还对应着一个打开的文件对象,跟你有没有close有关,跟网络通不通毫无关系。新手最常见的写法是维护一个bool connected标志位,connect()成功了就置 true,close()了置 false,然后业务代码到处查这个标志。这个标志唯一能反映的是"我在某个时刻曾经成功过",仅此而已。

第二层是内核 TCP 状态层。Linux 上可以通过ss -tinp或者getsockopt(TCP_INFO)看到ESTABLISHED、CLOSE_WAIT、FIN_WAIT2这些状态。这一层比第一层可靠得多,因为它是内核维护的真实状态机。但它依然有盲区:对端掉电、网线被拔、NAT 表项被回收,这些情况内核完全不知道,它会一直认为连接是ESTABLISHED,直到你主动发包触发重传失败或者收到 RST。

第三层是协议可达层。要确认对端还在,必须有一次成功的往返。TCP KeepAlive 和应用层心跳干的都是这件事。区别只是探测包由谁发:内核发的是不含数据的 ACK 探测段,应用发的是你自己定义的业务帧。

第四层是业务可用层。就算 TCP 通了,对端进程可能已经卡死、数据库连接断了、消息队列堵了。这一层只能靠业务语义判断,比如心跳响应里带上对方的事件循环延迟、队列积压数。

写判断逻辑之前先问自己:我要的是哪一层?如果只是想避免向已关闭的连接写数据导致异常,第二层加一点补充探测就够了;如果是计费用长连接,那必须打到第三层甚至第四层。

1.2 三种常见错误判法,以及它们为什么错

第一种错法就是上面说的标志位法。它的错误在于把"历史事实"当成"当前状态"。TCP 是一个没有"连接状态查询"语义的协议,协议本身不提供"你还活着吗"这种请求,你只能通过后续的交互间接推断。

第二种错法是用send的返回值判断。很多代码这么写:if (send(fd, buf, len, 0) < 0) { 连接已断 },看起来没毛病,但实际有两个大坑。一是发送缓冲区有空位时,数据只是被拷贝进了内核缓冲区,send立刻返回成功,此时连接即使在几毫秒前已经收到 RST,你也感知不到。真正的问题要等到下一次send或者内核重传失败才会暴露,这就是著名的"第一次写成功、第二次写才报错"现象。二是当对端正常关闭(发了 FIN)而你还没读走 EOF 时,send依然可能成功,因为 TCP 的半关闭特性允许单向传输。

第三种错法是只靠SO_KEEPALIVE。默认参数下,Linux 的tcp_keepalive_time是 7200 秒,也就是说空闲两小时后才开始探测,探测次数 9 次、间隔 75 秒,加起来差不多两小时十一分钟才发现死链。业务上根本等不起。Windows 默认也是两小时。直接开SO_KEEPALIVE不做参数调整,等于没做。

还有一种隐蔽的错法:用select的可写事件判断。很多人想当然觉得"能写说明连接通畅",实际上在正常情况下发送缓冲区几乎总是有空位的,可写事件会一直触发,这个信号的分辨率接近于零,反而会让 CPU 空转。

1.3 判断精度与代价的取舍表

不同场景对判断精度的要求差得很远,下面这张表是我做技术选型时的参考,建议你也按自己的场景对号入座。

判断手段能发现的问题发现延迟系统开销适用场景
标志位 / fd 有效性仅本地 close立即几乎为零不可单独使用
recv返回 0 / 错误码对端 FIN、RST收到包即刻极低有数据交互的连接
SO_ERROR检查异步 connect 失败、迟到的 RST事件触发时极低连接池取用前校验
select/poll读事件同上事件触发时低事件循环内顺手做
TCP KeepAlive(调参后)死链、半开连接秒到分钟级低长期空闲的长连接
应用层心跳死链、对端进程假死一个心跳周期中网关、IM、推送
业务级探活对端服务不可用一个探活周期高关键链路

我的经验是:不要指望单一手段解决所有问题。一个健壮的长连接模块通常是三层组合:事件循环里用读事件和SO_ERROR做实时感知,空闲时靠调过参的 KeepAlive 兜底,业务层用心跳做最终仲裁。这三层各管一段,互不冲突。

2. 内核告诉我们什么:可读可写事件与 SO_ERROR 的正确用法

这一节讲的是第二层判断的落地细节,也就是如何从内核拿到可靠信号。核心思路只有一句话:内核不会主动告诉你连接断了,但它会在连接出问题时,把这个事实挂在 socket 的可读事件或者错误队列上。你要做的就是学会听。

2.1 读事件:recv 返回 0 和 ECONNRESET 才是硬信号

TCP 连接的两条关闭路径在内核侧的表现是不一样的,必须区分开。

对端正常关闭时,会发一个 FIN 段。内核收到后把连接状态推到CLOSE_WAIT,并把这个 socket 标记为可读。此时你调recv,返回值为0,errno不变。这是最干净的信号,看到recv返回 0 就说明对端不会再发数据了,你应该close自己这一侧(如果还有数据要发,可以先把发完再 close,半关闭的场景)。

对端异常关闭时,比如进程崩溃、机器重启、中间设备主动踢连接,会发 RST 或者你收到的是 RST。内核收到 RST 后会把连接标记为错误,recv返回-1,errno为ECONNRESET。有些情况下 RST 到达时你的进程正在send,那么send会返回-1并带EPIPE,同时内核会给你发一个SIGPIPE信号,默认行为是直接杀掉进程。这是新手最容易中的招,后面 4.4 节会专门讲怎么处理。

这里有个非常关键的细节:recv返回 0 只代表读方向结束了,写方向可能还没结束。如果你不管三七二十一直接close,那么缓冲区里还没发出去的数据就丢了。正确做法是先判断是否还有待发数据,没有的话再close,或者用shutdown(fd, SHUT_WR)先关写方向,等对端也关掉之后再彻底close。

还有一个容易被忽略的点:recv返回-1且errno == EINTR时,这是被信号打断,不是连接问题,必须重试;errno == EAGAIN或EWOULDBLOCK在非阻塞 socket 上表示暂时没数据,也不是错误。这三种情况必须分开处理,混在一起判断就会误杀正常连接。

2.2 写事件为什么靠不住

我在代码评审里见过太多次这样的逻辑:select返回可写,就认为连接健康。这个判断最多只能说明"本地的发送缓冲区还有空间",跟对端在不在没有任何关系。

从内核实现角度看,一个 TCP socket 的发送缓冲区空闲时,poll会一直返回POLLOUT,这是常态而非事件。只有在发送缓冲区被填满(比如对端窗口为 0、或者网络拥塞导致数据积压)时,POLLOUT才会消失,等到缓冲区腾出空间后再出现。所以你在这个信号上做文章,得到的是"背压"信息,不是"存活"信息。

写事件真正有价值的场景只有两个。一是非阻塞connect完成后判断结果,这个后面会详细讲。二是配合背压控制做流量调度,比如发送队列满了就暂停读取上游数据。至于存活判断,老老实实用读事件和心跳。

顺便说一句,POLLERR和POLLHUP这两个标志在poll/epoll里是"总是被监听"的,你不需要在events里显式声明它们也会被报告。POLLHUP表示挂断,通常和对端关闭相关;POLLERR表示出错,比如收到 RST。但这两个标志的含义在不同平台上有些微差异,最稳的做法还是收到之后统一走一次recv探测来确认。

2.3 SO_ERROR 的读取时机与"读一次就清空"的坑

SO_ERROR是一个挂起错误(pending error)的读取接口,用法是getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len)。它有两个特性必须记住。

第一,读一次就清空。内核把错误码存在 socket 上,你读走之后它就归零了。这意味着你不能在两处代码里各自读一次做判断,第二次读到的永远是 0。我见过一个 bug 就是这么产生的:框架层在事件循环里读了SO_ERROR做日志,业务层再去读的时候拿到 0,误判成健康。解决办法是框架层读完把结果存下来传给业务层,而不是各读各的。

第二,它只反映异步操作的错误和部分连接级错误。非阻塞connect返回EINPROGRESS之后,连接的最终结果就是通过SO_ERROR拿到的:值为 0 表示连接建立成功,非 0 就是失败原因,比如ECONNREFUSED、ETIMEDOUT、ENETUNREACH。另外连接建立之后如果收到 RST,内核也会把这个错误挂上去,此时SO_ERROR能读到ECONNRESET。

但SO_ERROR不会告诉你对端正常关闭(FIN)。FIN 是正常的数据流结束,内核用"可读 + recv 返回 0"来表达,不算错误。所以完整的检查顺序应该是:先看可读事件,recv探测;没数据再看SO_ERROR;两者都没有,才算初步健康。

2.4 EPOLLRDHUP 与 POLLHUP 的差异

Linux 上epoll有个很实用的扩展事件EPOLLRDHUP,从 2.6.17 开始支持。它专门表示"对端关闭了连接或者关闭了写方向",比你手动recv探测要直接得多,而且不会有消费数据的风险。使用时需要在epoll_ctl的events里显式加上这个标志,然后在事件返回时检查revents & EPOLLRDHUP。

// Linux epoll 中处理对端关闭 struct epoll_event ev; ev.events = EPOLLIN | EPOLLRDHUP | EPOLLET; ev.data.fd = fd; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // 事件循环中 if (revents & EPOLLRDHUP) { // 对端已关闭写方向,注意:可能还有残留数据未读 handle_peer_shutdown(fd); } if (revents & (EPOLLERR | EPOLLHUP)) { int err = 0; socklen_t len = sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len); handle_socket_error(fd, err); }

注意EPOLLRDHUP触发时,socket 上可能还有未读数据。如果你直接close,这些数据就丢了。稳妥的做法是先把可读数据读干净,读到recv返回 0 为止再关闭。

POLLHUP则语义更宽泛一些,在某些平台(包括 Windows 的WSAPoll)上表现不一致,历史上还有过WSAPoll对连接失败报告不准确的已知问题。跨平台代码里我的建议是:不要依赖POLLHUP做判断,统一用读事件加recv探测,这样可以绕开大量的平台差异。

3. 主动探测:KeepAlive 参数调优与应用层心跳

被动等待信号的问题是:如果对端是掉电、拔网线、休眠这类"沉默式消失",内核永远收不到 FIN 或 RST,你也就永远等不到读事件。这时候必须主动出击。

3.1 Linux 上 KeepAlive 的三个参数怎么算

TCP KeepAlive 的探测机制是这样的:连接空闲超过keepidle时间后,内核开始发探测段(一个不含数据的 ACK,序号是已发送的最大序号减一,对端必须回应)。如果没收到回应,每隔keepintvl秒再发一次,最多发keepcnt次,全部失败就判定连接死亡,把错误挂到 socket 上。

Linux 上可以用setsockopt直接按连接配置,这比改/proc/sys/net/ipv4/tcp_keepalive_*全局参数好得多,因为不同连接的存活要求不一样:

int fd = socket(AF_INET, SOCK_STREAM, 0); int on = 1; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)); int idle = 30; // 30 秒无数据开始探测 int intvl = 5; // 探测间隔 5 秒 int cnt = 3; // 连续 3 次失败判死 setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt));

算一下最坏情况下的发现时间:30 + 5 × 3 = 45秒。这个量级对大多数业务是可接受的。如果业务要求更严格,把keepidle调到 10 秒、keepintvl调到 2 秒,最坏 16 秒就能发现,代价是探测包变多。我的经验值是:内网服务 30/5/3,公网长连接 60/10/3,前者追求快速发现,后者避免探测包被中间设备当成异常流量。

Linux 上还有个更精细的选项TCP_USER_TIMEOUT,它控制的是"已发送数据多久没被 ACK 就判定超时"。这个参数对 KeepAlive 有加成效果:如果探测段发出后迟迟没有 ACK,TCP_USER_TIMEOUT会让内核更早放弃。我一般把TCP_USER_TIMEOUT设成keepidle + keepintvl × keepcnt的同等量级,两者配合能显著缩短死链检测时间。

unsigned int user_timeout = 45000; // 毫秒 setsockopt(fd, IPPROTO_TCP, TCP_USER_TIMEOUT, &user_timeout, sizeof(user_timeout));

3.2 Windows 上 SIO_KEEPALIVE_VALS 的坑

Windows 的 KeepAlive 参数不能靠setsockopt设置,必须用WSAIoctl的SIO_KEEPALIVE_VALS命令。结构体定义如下,注意时间单位是毫秒,而不是 Linux 的秒。

// Windows struct tcp_keepalive { u_long onoff; // 1 开启,0 关闭 u_long keepalivetime; // 空闲多久开始探测,毫秒 u_long keepaliveinterval; // 探测间隔,毫秒 }; int on = 1; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, (const char*)&on, sizeof(on)); tcp_keepalive ka; ka.onoff = 1; ka.keepalivetime = 30000; // 30 秒 ka.keepaliveinterval = 5000; // 5 秒 DWORD bytes = 0; WSAIoctl(fd, SIO_KEEPALIVE_VALS, &ka, sizeof(ka), NULL, 0, &bytes, NULL, NULL);

这里的坑在于:重试次数不可配。Windows 上SIO_KEEPALIVE_VALS只让你设置起始时间和间隔,探测次数固定为 10 次。也就是说按上面的参数,最坏发现时间是30 + 5 × 10 = 80秒,比 Linux 上同样参数要长得多。如果你需要更短的检测时间,只能把keepaliveinterval调小,或者干脆改用应用层心跳。

另外有个细节:SO_KEEPALIVE和SIO_KEEPALIVE_VALS的顺序无所谓,但WSAIoctl必须在 socket 已经创建之后调用,且部分旧系统上对未连接的 socket 调用会失败。我的习惯是先connect成功再设,或者至少在调用后检查WSAIoctl的返回值,别默默失败了还不知道。

还有一点:Windows 从 Vista 起支持通过SetFileInformationByHandle配合FileIoCompletionNotificationInformation之外的路径改全局 KeepAlive 参数,但那属于系统级配置,改的是整机行为,不适合按连接控制,生产环境里慎用。

3.3 应用层心跳的设计要点

KeepAlive 再调参,也只能证明"TCP 链路可达"。如果对端进程死锁了、事件循环卡住了、磁盘 IO 阻塞了,内核依然会替你回 ACK,KeepAlive 依然通过。这时候只有应用层心跳能发现问题。

心跳设计我有几个固定原则。第一,探测帧和响应帧必须能配对。最简单的方式是带一个自增序号,收到响应时校验序号是否匹配。如果只发不带序号的 ping,收到一个pong你根本不知道它是回应哪一次请求的,就容易出现"用旧响应证明当前健康"的误判。

第二,记录发送时间戳,用 RTT 做辅助判断。如果 ping 的往返时间突然从 5ms 涨到 3 秒,即使还没到超时阈值,也说明链路质量在恶化,可以考虑主动降级或者提前重连。

第三,心跳间隔和超时阈值要分离。常见的错误做法是"每 30 秒发一次心跳,30 秒没收到就断"。这样一次网络抖动就会断连。合理的比例是心跳间隔的 2 到 3 倍作为超时阈值,比如 30 秒发一次,90 秒没收到响应才判定死亡。

第四,心跳包要尽量小。心跳是长期的固定开销,一条 10 万连接的服务,每 30 秒一次心跳,如果每个包 200 字节,一天就是100000 × 2880 × 200字节,约 57 GB 的纯心跳流量。控制在几十字节的量级,对带宽和中间设备都友好。

3.4 半开连接与超时判定

"半开连接"这个词在两种语境下含义不同。TCP 协议层面的半开是指一方已关闭写方向(发了 FIN),另一方还可以继续发数据;运维语境下的半开连接通常指"一端认为连接还在,另一端已经消失了"。后者才是我们要解决的问题。

判断半开连接的核心是给"沉默"设一个上限。如果一条连接在T时间内既没有收到数据,也没有收到心跳响应,就应该判定为可疑;如果持续到2T,直接断开重连。这个T的取值要参考链路上最慢的那个中间设备。比如经过负载均衡器时,很多设备的空闲连接回收时间是 300 秒或 900 秒,你的心跳间隔必须明显小于这个值,否则连接会被设备先回收掉,而两端还以为连着呢。我一般的取值是心跳间隔取中间设备空闲超时的 1/3 到 1/2,比如设备 300 秒回收,心跳就设 60 到 100 秒。

4. 跨平台封装实操:从非阻塞 connect 到存活检测

前面都是原理和单点技巧,这一节把它们拼成能直接落地的东西。跨平台代码最难的地方不是逻辑,是那些细微的语义差异,我按实际踩过的顺序讲。

4.1 非阻塞 connect 的完整流程

非阻塞connect的流程在 Linux 和 Windows 上步骤一样,但常量和错误码不同。核心步骤是:设为非阻塞、调connect、判断返回值、等可写事件、读SO_ERROR确认结果。

// 伪代码,宏定义见 4.2 节 int nonblock_connect(int fd, const struct sockaddr* addr, socklen_t len, int timeout_ms) { set_nonblocking(fd, 1); int rc = connect(fd, addr, len); if (rc == 0) { return 0; // 立即成功,通常是本机地址 } if (!IS_INPROGRESS(errno)) { return -1; // 立即失败,如 ECONNREFUSED } // 等待可写事件 int ready = wait_writable(fd, timeout_ms); // 内部用 select/poll/WSAPoll if (ready <= 0) { return -1; // 超时或等待失败 } // 关键:必须读 SO_ERROR 才能确定最终结果 int err = 0; socklen_t elen = sizeof(err); if (getsockopt(fd, SOL_SOCKET, SO_ERROR, (char*)&err, &elen) < 0) { return -1; } if (err != 0) { errno = err; return -1; } return 0; }

其中IS_INPROGRESS的判断:Linux 上是errno == EINPROGRESS,Windows 上是WSAGetLastError() == WSAEWOULDBLOCK。注意 Windows 的connect在非阻塞模式下返回SOCKET_ERROR,并且WSAEWOULDBLOCK才是"正在进行中"的标志,不是WSAEINPROGRESS(后者用于阻塞 socket 的重入检测)。这个差异我见过不少人写错,结果在 Windows 上所有连接都被判成失败。

还有一点:select用可写集合监听非阻塞连接,在连接失败的情况下也会返回可写,这就是为什么必须读SO_ERROR。如果只看"可写"就认为连接成功,遇到端口不通的情况会误判。这个坑 Linux 和 Windows 都有。

4.2 存活检测函数的实现(Linux + Windows)

下面这个版本是我在实际项目里用的简化版,思路是"用平台原生事件接口做一次即时探测",时间复杂度 O(1),适合在连接池取用前调用。

// 返回:1 存活(无异常信号),0 已断开,-1 调用出错 int socket_alive_check(int fd) { #ifdef _WIN32 WSAPOLLFD pfd; pfd.fd = (SOCKET)fd; pfd.events = POLLRDNORM; pfd.revents = 0; int rc = WSAPoll(&pfd, 1, 0); if (rc == SOCKET_ERROR) { return -1; } if (rc == 0) { // 没有可读事件,但不代表安全,仍需看错误 int err = 0, len = sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, (char*)&err, &len); return err == 0 ? 1 : 0; } if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) { return 0; } if (pfd.revents & POLLRDNORM) { char c; int n = recv((SOCKET)fd, &c, 1, MSG_PEEK); if (n == 0) return 0; // 对端 FIN if (n < 0) { int e = WSAGetLastError(); if (e == WSAEWOULDBLOCK) return 1; // 只是没数据 return 0; // 其他错误一律判死 } return 1; // 有数据,连接正常 } return 1; #else struct pollfd pfd; pfd.fd = fd; pfd.events = POLLIN; pfd.revents = 0; int rc = poll(&pfd, 1, 0); if (rc < 0) { return -1; } if (rc == 0) { int err = 0; socklen_t len = sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len); return err == 0 ? 1 : 0; } if (pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) { return 0; } if (pfd.revents & POLLIN) { char c; ssize_t n = recv(fd, &c, 1, MSG_PEEK | MSG_DONTWAIT); if (n == 0) return 0; // 对端 FIN if (n < 0) { if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) return 1; return 0; } return 1; } return 1; #endif }

这里解释几个设计决定。用MSG_PEEK是为了不消费数据,避免把业务数据"吃掉",探测完之后数据还在缓冲区里。用poll而不是select是因为select的 fd 集合操作开销大,且fd有 1024 上限(虽然 Windows 上select的实现更好,但WSAPoll更现代)。超时设为 0 表示纯非阻塞,立刻返回,不占用事件循环。

注意返回值的语义:这个函数返回 1 只代表"此刻没有发现异常",不代表连接一定健康。对端掉电这种情况它查不出来,必须靠 3.3 节的心跳。我在注释里写得很明确,就是防止调用方过度自信。

4.3 Windows 上获取连接细节的替代方案

如果你需要在 Windows 上拿到类似 LinuxTCP_INFO的连接指标(重传次数、RTT、拥塞窗口),从 Windows 10 1709 开始可以用WSAIoctl(SIO_TCP_INFO)拿到TCP_INFO_v0结构体:

// Windows 10 1709+ #include <mstcpip.h> TCP_INFO_v0 info; DWORD bytes = 0; DWORD ver = 0; int rc = WSAIoctl(sock, SIO_TCP_INFO, &ver, sizeof(ver), &info, sizeof(info), &bytes, NULL, NULL); if (rc == 0) { // info.State 是 MIB_TCP_STATE 枚举 // info.BytesIn / BytesOut / Rtt / Rto 等 }

TCP_INFO_v0里的State字段对应MIB_TCP_STATE_ESTAB、MIB_TCP_STATE_CLOSE_WAIT这些枚举值,可以用来做状态判断,比getsockopt(SO_ERROR)更直观。但要注意这个接口在 Windows 10 之前的系统上不可用,调用会返回WSAEOPNOTSUPP,所以必须做可用性判断,不能无条件依赖。

另一个常用的 Windows 专有接口是SIO_IDEAL_SEND_BACKLOG_QUERY,用来查询理想的发送积压量,主要服务于自适应发送策略。它跟连接存活判断没直接关系,但在做发送拥塞控制时会用到,顺便提一句。

至于 Linux 上用来查"发送队列里还有多少字节没被 ACK"的SIOCOUTQ(TIOCOUTQ),Windows 没有直接等价物。我在跨平台代码里的做法是自己在应用层维护一个已发送但未收到确认的字节计数器,用业务层的 ACK 来扣减。这样虽然不如内核精确,但足以支撑"关闭前等待数据发完"这类逻辑,而且跨平台一致。

Linux 侧的用法是这样:

#include <linux/sockios.h> int pending = 0; ioctl(fd, SIOCOUTQ, &pending); // 返回未发送/未确认的字节数 if (pending == 0) { // 发送队列已空,可以安全关闭 }

4.4 SIGPIPE 与 MSG_NOSIGNAL

这块单独拎出来说,因为它是"判断连接状态"这个话题里最容易引发线上事故的分支。

在 Linux 上,当你向一个已经收到 RST 的 socket 写数据,内核会给进程发SIGPIPE信号,默认行为是终止进程。想象一下,你的服务里有 10 万条连接,其中一条断了,你往里写了点数据,整个进程就没了。这个故障模式在监控上表现为"进程突然消失,无 core dump(取决于配置)",极难定位。

标准解法有两个,任选其一或都用。第一个是全局忽略SIGPIPE:

#include <signal.h> signal(SIGPIPE, SIG_IGN);

第二个是在每次发送时带上MSG_NOSIGNAL标志:

ssize_t n = send(fd, buf, len, MSG_NOSIGNAL);

我个人的偏好是两者都用:全局忽略做兜底,防止遗漏的代码路径;关键发送路径加标志,做到局部可控。注意这两个都是 Linux/POSIX 的东西,Windows 上不存在SIGPIPE,也没有MSG_NOSIGNAL标志,直接写会编译报错。跨平台代码里必须用宏隔离:

#ifdef _WIN32 #define SEND_FLAGS 0 #else #define SEND_FLAGS MSG_NOSIGNAL #endif ssize_t n = send(fd, buf, len, SEND_FLAGS);

Windows 上向已断开的 socket 发送,send会直接返回SOCKET_ERROR,WSAGetLastError()得到WSAECONNRESET或WSAECONNABORTED。不会杀进程,但必须检查返回值,否则你会以为数据发出去了。

5. 线上排查:从现象到定位的路径

原理讲完,落到最实用的部分:线上出问题时怎么快速定位。我的经验是,判断"连接状态"出问题,90% 的情况能归到有限的几类现象里,先按现象对号入座,再去验证,比盲目抓包高效得多。

5.1 现象速查表

现象最可能的根因验证方法处理方向
大量CLOSE_WAIT堆积应用收到 FIN 后没调用closess -tan state close-wait看数量检查读事件处理分支是否漏了recv == 0的情况
大量TIME_WAIT主动关闭方频繁短连接ss -s看统计上连接池,或用SO_LINGER谨慎处理
send偶发EPIPE对端已 RST,写操作才发现加MSG_NOSIGNAL并看日志在写失败前做存活探测,减少无效写
连接数只增不减半开连接未检测ss -tni看是否有重传调 KeepAlive + 上心跳
connect超时无响应中间设备丢包或对端 backlog 满用tcpdump看 SYN 有无 SYN-ACK缩短 connect 超时,加重试
客户端报连接正常但收不到数据对端进程假死心跳 RTT 变大但 TCP 层正常应用层心跳加业务状态上报
Windows 端连接偶发失败但 Linux 正常错误码判断差异打印WSAGetLastError()检查是否误判了WSAEWOULDBLOCK

这张表我贴在自己的排查笔记首页,基本上看一眼就知道往哪个方向查。特别说一下CLOSE_WAIT:这个状态堆积只有一个原因,就是对端发了 FIN,你的内核回了 ACK,但你的应用没有调用close。这是纯粹的代码问题,不是网络问题,别去抓包浪费时间。

5.2 用 ss、netstat 和抓包验证你的判断

Linux 上ss比netstat好用得多,信息全且快。几个我常用的组合:

# 看所有 TCP 连接状态统计 ss -s # 看指定端口的连接,带定时器信息 ss -tan 'sport = :8080' # 看连接的内核细节:重传、RTT、拥塞窗口 ss -tin 'dport = :8080' # 只看处于特定状态的连接 ss -tan state established ss -tan state close-wait

ss -ti输出的信息量很大,重点关注几个字段:rtt反映链路质量,retrans是重传次数(持续增长说明丢包严重),unacked是已发送未确认的段数(长期大于 0 说明对端接收有问题),timer显示当前挂的定时器类型(keepalive表示正在做 KeepAlive 探测)。

Windows 上用netstat -ano配合Get-NetTCPConnection电源壳命令,后者能直接按状态过滤:

Get-NetTCPConnection -State Established | Where-Object { $_.LocalPort -eq 8080 } Get-NetTCPConnection -State CloseWait

抓包验证是最终手段。判断一条连接到底谁先断的,看tcpdump里 FIN 的方向最直接:tcpdump -i eth0 -nn 'tcp port 8080 and (tcp[tcpflags] & tcp-fin != 0)'。如果 RST 是从中间设备来的,源 IP 会是网关地址而不是对端地址,这个特征很关键,能直接告诉你问题出在链路上而不是对端应用上。

5.3 踩过的坑清单

下面这几条都是我在实际项目里真真切切踩过的,写出来给大家省点时间。

第一个坑:以为recv返回 0 之后就安全了。实际上recv返回 0 之后如果继续send,数据会进入内核缓冲区但永远发不出去,最终超时后报错。正确做法是收到 0 就停止写,进入关闭流程。

第二个坑:在epoll边缘触发模式下漏读。边缘触发要求你把数据一次性读干净,如果只读一次就返回,剩下的数据不会再触发事件。更糟的是,如果对端关闭的 FIN 藏在未读数据后面,你可能永远等不到那个"返回 0"的信号。标准写法是循环读到EAGAIN为止。

第三个坑:Windows 上WSAPoll的历史缺陷。早期 Windows 版本的WSAPoll有个已知问题:连接被拒绝时不会正确报告POLLERR,导致非阻塞连接的失败检测不可靠。规避方式是在WSAPoll返回后一律读SO_ERROR做二次确认,不要只看revents。

第四个坑:SO_ERROR被别处读走了。前面提过,这个错误码读一次就清零。如果中间件框架里已经读过了,业务层就读不到。解决办法是框架层把错误码作为事件参数传下来,或者在封装层统一做一次检测并把结果缓存。

第五个坑:心跳和 KeepAlive 同时开,参数没对齐。有一次我们把心跳设成 30 秒,KeepAlive 的keepidle设成 300 秒。结果网络抖动时心跳先超时断连重连,KeepAlive 根本没来得及起作用,日志里一堆重连记录但看不到 KeepAlive 相关的错误。后来把两者参数对齐,让 KeepAlive 略早于心跳超时,就能在心跳判定之前先拿到内核层面的证据,便于区分是链路问题还是应用问题。

6. 一套可以落地的检测策略

最后聊聊怎么把这些零散的手段组织成一套完整的策略。纯粹堆功能没用,关键是让不同层次的手段各司其职,互不干扰。

6.1 连接池场景:取用前校验,归还时降级

连接池的核心矛盾是"池里的连接可能已经死了,但你还得快速把它捞出来用"。我的做法是双管齐下。

取用前做一次 O(1) 的即时检测,就是 4.2 节那个socket_alive_check。如果返回 0,直接从池里剔除并新建连接;返回 1 就放心用。这个检测成本极低,一次poll加一次getsockopt,对性能影响可以忽略。

归还时不要立刻关闭,而是根据使用情况分级。如果一个连接在借出期间发生过错误(比如send失败过一次),归还时标记为不健康,下次取用时优先淘汰。如果连接空闲时间超过阈值(比如 5 分钟),归还时直接关闭而不是放回池里,避免池里躺着一堆陈年连接。

另外池子里的连接要定期做后台巡检。用TCP_INFO看retrans和unacked,异常的就剔除。这个巡检频率不用高,一分钟一次足够,因为它是兜底而不是主路径。

6.2 长连接网关场景:三层组合

网关的特点是连接数多、单连接流量小、对断连敏感。这里的策略要更立体。

第一层是内核事件层,事件循环里监听EPOLLIN | EPOLLRDHUP | EPOLLERR,收到就立刻走探测确认,能识别 FIN 和 RST。这一层负责"对端主动断开"的情况,延迟最低。

第二层是KeepAlive 兜底层,参数按 3.1 节的方式调,比如 60/10/3,最坏 90 秒发现死链。这一层负责"对端静默消失"的情况,而且是内核在做,不占应用 CPU。

第三层是应用心跳层,60 秒一次,超时阈值 180 秒。这一层负责"对端进程假死"和"链路质量恶化",同时也是业务健康的观测窗口。

三层的检测时间要设计成递减的关系:事件层是秒级,KeepAlive 是分钟级,心跳也是分钟级但语义更丰富。这样当问题发生时,你会先看到事件层的日志,如果事件层没反应而心跳超时了,基本可以确定是"静默型故障",排查方向就很明确了。

6.3 参数配置与代码骨架

把上面这些整理成一份配置清单,新项目直接抄:

// 连接建立后立即设置 void tune_socket(int fd, int is_public_net) { int on = 1; setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &on, sizeof(on)); #ifdef _WIN32 tcp_keepalive ka = { 1, is_public_net ? 60000 : 30000, 10000 }; DWORD bytes = 0; WSAIoctl(fd, SIO_KEEPALIVE_VALS, &ka, sizeof(ka), NULL, 0, &bytes, NULL, NULL); #else int idle = is_public_net ? 60 : 30; int intvl = 10; int cnt = 3; setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &idle, sizeof(idle)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &intvl, sizeof(intvl)); setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &cnt, sizeof(cnt)); unsigned int ut = (idle + intvl * cnt) * 1000u; setsockopt(fd, IPPROTO_TCP, TCP_USER_TIMEOUT, &ut, sizeof(ut)); #endif // 关闭 Nagle,小包场景降低延迟 setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)); }

配套的事件处理骨架,伪代码级别,重点是分支顺序:

void on_socket_event(int fd, uint32_t revents) { if (revents & (ERR_BIT | HUP_BIT | RDHUP_BIT)) { int err = read_and_clear_so_error(fd); // 只读一次,结果传下去 if (err != 0) { log_error(fd, err); close_and_notify(fd); return; } } if (revents & READ_BIT) { // 边缘触发:循环读到 EAGAIN for (;;) { ssize_t n = recv(fd, buf, sizeof(buf), 0); if (n > 0) { dispatch(fd, buf, n); continue; } if (n == 0) { close_and_notify(fd); return; } // 对端 FIN if (errno == EAGAIN || errno == EWOULDBLOCK) break; if (errno == EINTR) continue; close_and_notify(fd); return; // ECONNRESET 等 } } if (revents & WRITE_BIT) { flush_send_queue(fd); // 发送队列空则取消监听写事件 } }

这套骨架我用了几个项目,最核心的一点体会是:把错误码读取集中到一处,把连接关闭收敛到一条路径。所有断开的原因都走同一个close_and_notify,在里面统一做资源清理、指标上报和重连调度。这样无论是因为 FIN、RST、超时还是主动关闭,都不会出现某个分支漏了清理导致 fd 泄漏的情况。我在早期项目里就是因为关闭路径分散在七八个地方,结果有一条路径少关了 fd,跑了两周才发现文件描述符耗尽。这种问题靠代码评审很难发现,只能靠结构上收敛来避免。

还有个小技巧值得分享:在close_and_notify里记录一下连接的生命周期时长和断开原因,做成一张分布图。用不了多久就会发现规律,比如"某类客户端平均 300 秒断一次"对应着中间设备的空闲回收配置,"某类断开集中在整点"可能是对端在做定时任务。这类观察比任何调试工具都直接。

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

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

立即咨询