在一个项目中使用设计了一下TCP服务器,使用epoll机制来提高服务器的性能,由于客户端的wifi模组,发完数据变会断电,不会走正常的TCP链接关闭流程,因此TCP服务器端需要主动关闭不在使用的链接。
A、TCP keepalive机制
为了不多写代码,我首先想到的是keepalive机制,让TCP协议端检测客户端的链接,如果没有反应,就在设定的超时时间后主动关闭链接。设置一个socket的keepalive超时的方法如下:
/*********************************************************************************************************
** Function name: set_tcp_timeout()
** Descriptions: set TCP timeout parameters
** input parameters: fd, keep_idle, keep_interval, keep_count
** output parameters: None
** Returned value: None
*********************************************************************************************************/
void set_tcp_timeout(int fd, int keep_idle, int keep_interval, int keep_count)
{
/* 开启TCP保活机制 */
int keep_alive = 1;
setsockopt(fd, SOL_SOCKET, SO_KEEPALIVE, &keep_alive, sizeof(keep_alive));
/* 30秒内无数据 → 开始探测 */
setsockopt(fd, IPPROTO_TCP, TCP_KEEPIDLE, &keep_idle, sizeof(keep_idle));
/* 探测间隔1秒(可改) */
setsockopt(fd, IPPROTO_TCP, TCP_KEEPINTVL, &keep_interval, sizeof(keep_interval));
/* 探测1次无响应就断开(= 30秒无数据立刻断开) */
setsockopt(fd, IPPROTO_TCP, TCP_KEEPCNT, &keep_count, sizeof(keep_count));
}
采用这个机制后,连接6个客户端正常处理,当连接到18个客户端时,服务端程序一启动后,有几个客户端的链接长时间不释放,时间长达15分钟后再翻译,程序内部设置的keepalive时间为20秒,应该有20秒就会释放, 抓包分析如下:

Wireshark 抓包分析:62541 与 56225 两条连接行为差异
节点说明:
- 服务端:
192.168.68.2:9500- 客户端:
192.168.68.105- 连接 A:客户端端口62541 ,异常:客户端主动发 RST 断开,服务端没有执行 keepalive 探测
- 连接 B:客户端端口56225 ,正常:服务端 9500 发出
TCP Keep‑Alive,最后服务端发出 RST 关闭连接
1、先看连接 A(62541)完整时序
- 三次握手正常建立连接。
- 客户端发 PSH+ACK 上报 14 字节业务数据;服务端回 ACK 确认。
- 服务端发出 PSH+ACK(15 字节应答),这个报文没有收到客户端 ACK ,开始
TCP Retransmission重传(连续多次重传服务端的应答包)。 - 重传超时还收不到对端 ACK → 客户端直接发送 RST|ACK(No.7346)把整条连接干掉。
⚠️关键点:这条连接在服务端还没机会走到 TCP Keep‑Alive 定时器,就因为服务端下行报文持续重传失败,客户端直接 RST 重置连接 。 服务端此时状态:在不断重传未被 ACK 的业务数据,TCP keepalive 不会在有未确认待重传数据时触发 。 TCP Keep‑Alive 仅针对连接完全空闲,没有待发送 / 待确认数据的 socket 才会启动保活探测;当 socket 有未 ACK 的出站报文,内核优先处理重传定时器,keepalive 定时器被搁置。
2、连接 B(56225)正常时序
- 三次握手建立;双向收发多轮业务数据。
- 业务交互结束,双向没有新业务报文,Socket 进入完全空闲状态。
- 此时服务端 socket 没有待确认的未完成报文,内核 TCP Keep‑Alive 定时器开始工作 ,连续发出
TCP Keep‑Alive探测包。 - 多次 keep‑alive 探测无应答后,服务端内核发出 RST 把这条连接销毁 ,就是抓包看到的 No.8327
9500 → 56225 [RST,ACK]。
3、核心根本原因:为什么两条连接表现不一样
① TCP Keep‑Alive 的内核重要规则
TCP Keep‑Alive 只对 IDLE 空闲连接生效;只要 socket 还有未收到 ACK 的发送队列数据,keep‑alive 定时器不会运行,内核跑的是重传定时器。
-
连接 A (62541) :服务端发送 15 字节应答包,客户端没有回复 ACK。服务端发送队列存在未确认数据 → 内核运行重传计时器,反复重传该数据包,keepalive 完全不启动。 多次重传后,客户端这边感知异常,直接从客户端侧发出 RST 重置连接。
现象:你看不到 9500 端口的 keep‑alive 包,直接看到客户端 RST。
-
连接 B (56225):所有双向业务数据包全部得到对方 ACK 确认,发送队列为空,socket 进入 IDLE 空闲;此时才启动 TCP keep‑alive;多次探测失败后,服务端内核发出 RST 断开。
② 为什么连接 A 客户端会发出 RST?
抓包里看到服务端不停重传9500→62541 [PSH,ACK],客户端这边收到这些重复的重传报文。 客户端 TCP 协议栈发现:当前接收窗口已经 ACK 过该序列号的数据,收到重复的旧报文,客户端 TCP 栈直接回复 RST 来销毁这条异常连接。
不是业务应用代码调用 close,是内核 TCP 协议栈自动回 RST,应对乱序 / 重复报文。
4、两条链路差异总结表
表格
| 项目 | 连接 A(客户端 62541) | 连接 B(客户端 56225) |
|---|---|---|
| Socket 状态 | 服务端存在未 ACK 的下行报文,非空闲 IDLE 状态 | 全部报文完成 ACK 确认,socket 空闲 IDLE |
| 内核定时器 | 重传定时器生效,不停重传业务报文 | Keep‑Alive 保活定时器生效,发送 keep‑alive 探测 |
| RST 来源 | 客户端内核回 RST(收到大量重复重传包) | 服务端内核发出 RST(keepalive 探测全部失败) |
| 能否看到 9500 的 Keep‑Alive 包 | ❌看不到,keepalive 不会启动 | ✅可以看到多条 TCP Keep‑Alive 报文 |
一句话概括现象:不是服务端没开启 keep‑alive,第一条连接因为存在未确认重传报文,还没轮到 keep‑alive 上场,连接就已经被客户端 RST 干掉;第二条链路业务全部 ACK 完毕空闲下来,keep‑alive 才正常工作。
因此keepalive机制对于处理客户端链接时,按设定的超时时间关闭链接的功能,通过测试发现,如果服务端给客户端发送数据,客户端没有应答,就像上面的连接A的状态,服务器会进入Linux 重传超时总耗时计算 (tcp_retries2),不会发送keepalive包,最后过超时时间约15分钟后,TCP协议栈才会主动关闭这个连接,导致这个连接在服务器端关闭的时间较长。
B、解决办法:
找到问题的原因后,解决办法很简单,就是在应用层检测客户端长时间未发送数据就主动关闭连接,关闭时由于客户端是断电的,不能走正常的TCP close流程,需要走让服务器送RST包来关闭,应用层的代码写法如下:
cpp
/*服务端发送RST包关闭连接, 因为客户端发送完数据立即断电 */
struct linger lng = { 1, 0 };
setsockopt(fd, SOL_SOCKET, SO_LINGER, &lng, sizeof(lng));
close(fd)
