云计算百科
云计算领域专业知识百科平台

别再怪 ET 模式漏事件了!深挖 C++ 接入网关 CLOSE_WAIT 暴涨背后的内核真相!

网关监控告警把群炸了:ss -tan | grep -c CLOSE_WAIT 在一台核心 C接入网关上以每天四千条的速度单调飙升,直到把进程的文件描述符配额(ulimit -n 65535)彻底耗尽,accept4() 疯狂抛出 EMFILE。诡异的是,上游微服务早已捕获异常超时并销毁了连接,而服务端 C 进程内存里的 std::shared_ptr<TcpConnection> 却死死咬住不放,本地 Linux 内核将其永久钉死在 CLOSE_WAIT,对端抓包看却全都是 ESTABLISHED。

当时我扫了一眼 Reactor 事件循环,笃定是边缘触发(EPOLLET)写漏了事件,当即把 epoll 从 ET 切回水平触发(LT),热重载,监控上的 fd 计数瞬间归零。

那次热修复之所以能「暂时起效」,跟 epoll 的触发模式关系极小。我只是用 C层重新接收水平通知的开销收窄了漏读窗口,把复现周期从一天拖成了三天。我掉进了一个现代 C 网络编程最典型的认知陷阱:把 C++ 对象的生命周期,等同于网络连接的真实生命周期。

我们在 C里写 RAII 封装 SocketFd、用 std::enable_shared_from_this 传递闭包、在 EventLoop 里注册事件回调,自以为掌控了连接。但在底层,这条所谓的「TCP 连接」本质上在三处独立的内存空间里各自存了一份完全解耦的状态副本:**你的 C 用户态进程、本端 Linux 协议栈内核、以及对端内核。** 它们只在极度稀疏的网络收发包与系统调用瞬间短暂对齐,其余时间全在各自暗中漂移。

IP 层从来不存连接状态,TCP 只好在两端内核各造一份 struct tcp_sock,你的 C进程手里握着第三份应用层状态。三份副本各自漂移,所有棘手的生产网络故障,本质上都只问两件事:这一刻哪两份副本发生了认知撕裂?谁该在何时负责发现? 本文将从现代 C Reactor 架构与内核源码出发,拆解 fs/eventpoll.c 里区分 LT 与 ET 的核心 7 行逻辑、__tcp_close() 拿 RST 顶替 FIN 的内核判据、未读完缓冲区即析构导致的断连灾难,以及内核为 TIME_WAIT 写入的 pure paranoia。

那次修复改对了现象,改错了根因

先把当时在 C++ 服务端与线上环境抓到的四个症状摆齐,它们后面会各自精确归位:

  • 症状一(C++ 用户态与本端内核撕裂):ss -tan 里 CLOSE_WAIT 单调增长永不消退,服务进程内持有的 Connection 实例数量与内核 socket 计数死锁。

  • 症状二(本端内核与对端内核撕裂):客户端侧早已 close() 并退出,服务端抓包却显示仍是 ESTABLISHED,两端内核对同一物理链路的认知截然相反。

  • 症状三(Reactor 调度开销激增):将 epoll 改为 LT 后 CLOSE_WAIT 暂时归零,但 C++ 事件循环线程的软中断(si)CPU 占用瞬间暴涨 6~8 个百分点。

  • 症状四(心跳探测机制滞后):同事为底层 socket 开启 SO_KEEPALIVE 后,次日清晨确实回收了一批孤儿连接,但清理动作全都僵死延迟在 2 小时之后。

当时我给出的技术复盘是「C++ 在 ET 模式下的 read() 循环没有读到 EAGAIN,少循环了一次导致漏掉了 FIN 包」。这个解释看似切中要害,能解释症状一和症状三,却对症状二和症状四完全无能为力。一个只能解释一半现象的理论模型,在生产环境必然会以新的形态复发。

真正把我拽回正轨的,是 Linux 源码 net/ipv4/tcp_minisocks.c 里关于 tcp_timewait_state_process() 的一段注释。内核开发者写道:TIME_WAIT 存在的主要目的,是当对端卡在 LAST_ACK 反复重传 FIN、而我方回送的 ACK 又在途丢失时,保证连接依然能被优雅闭合——那句原话 one or more of our ACKs are lost,讲的完全是我方发出的 ACK 报文。

这段注释点醒了我:本端 C++ 业务哪怕早已完成计算并销毁了所有资源,Linux 内核仍要在内存中死守 60 秒,仅仅是为了给对端那份可能失步的状态副本兜底。

从这一刻起,整套网络架构的认知被彻底重构。我们平时在 C里通过 std::unique_ptr 或智能指针管理的不是什么「网络管道」,而仅仅是第三份应用层副本。内核眼里根本没有连接,只有一堆由定时器驱动的本地结构体,以及一套用来推测对端状态的概率性规则。CLOSE_WAIT 是 C 代码未响应内核关闭事件的结果,TIME_WAIT 是内核为对端失步买的单,而 ET 与 LT,不过是决定你的 C++ 事件循环何时、以何种代价去同步这三份副本。

我那次热修,仅仅是在 C++ 层给第三份副本打了块止痛贴。

一条连接同时活在三处内存里

把 TCP 的复杂度归到一句话:IP 是无状态的,而应用要的是有状态的字节流,于是状态只能由两端各自保存一份,靠报文来回同步。RFC 9293 在 §3.5.1 定义半开连接时把这层意思写得很直白:

text

An established connection is said to be "half-open" if
one of the TCP peers has closed or aborted the
connection at its end without the knowledge of the
other.

without the knowledge of the other 是整份文档里最关键的六个词。标准本身就承认,两端对同一条连接的认知可以不一致,而且它把这种不一致命名成了一个正式状态。

第三份副本标准里没写,因为它在标准的管辖范围之外,但它对写服务端的人最重要。你的进程手里握着的东西是:一个 fd、一份「上次 epoll 告诉我的事件」、一份「我以为对端还在」的业务假设。这三样加起来就是应用层对这条连接的认知,它跟内核那份靠系统调用同步,而系统调用是稀疏的、由你自己决定什么时候发起的。

text

进程副本 本端内核副本 对端内核副本
┌──────────┐ ┌────────────┐ ┌────────────┐
│ fd 4 │ │ tcp_sock │ │ tcp_sock │
│ 事件缓存 │◄────►│ state │◄────►│ state │
│ 业务假设 │ 系统 │ seq / ack │ 报文 │ seq / ack │
└──────────┘ 调用 └────────────┘ └────────────┘
▲ ▲ ▲
你决定何时同步 内核决定何时同步 对端决定

三条同步链路的性质完全不同,这是后面所有题目的分水岭。

进程与本端内核之间:同步是你主动发起的,靠 read / write / epoll_wait / getsockopt。内核可以推一个通知给你,但推完就算完成了义务,它并不检查你有没有真的去读。

本端内核与对端内核之间:同步是报文驱动的,只有真的发出去一个包并收到回应,两份副本才对得上。链路空闲时,两端可以任意漂移,谁也发现不了谁。

进程与对端内核之间:没有直接链路。你对对端的所有认知,都是本端内核转述给你的二手信息,而本端内核的信息本身也可能过期。

这个模型有两处必须先说清的失效点,否则它会把人带到错的方向去。

它数出来的三份,只在两端直连时才是三份。 现实链路上每一个有状态的中间设备——NAT、四层负载均衡、带会话表的防火墙——都各自持有一份,而它们的老化规则跟两端内核无关,也不受任何一端控制。长连接上那种「两端都说自己一切正常、包就是不通」的故障,成因通常就在这些副本里。准确的说法是「三份,加上路径上每一跳有状态设备各一份」;我用三份,是因为前三份能解释绝大多数落在代码层面的现象,一旦故障的形状变成「两端都正常」,就得往路径上找第四份。

它对纯吞吐与延迟类问题完全无能为力。 Nagle 与延迟确认互相等待造成的那种四十毫秒毛刺、缓冲区膨胀、多打一的 incast——这些场景里三份副本自始至终保持一致,慢是排队造成的,跟谁知不知道谁毫无关系。拿一个讲一致性的模型去解释性能现象,是它最容易被误用的方式,而且误用之后会一直在错的地方找证据。

我把这套东西叫做三副本模型,接下来的每一节都是在问同一组问题:这一刻哪两份副本分歧了,分歧由谁发现,发现要多久。九道常见题在这个坐标系里各占一格。

题目

分歧发生在哪两份之间

水平触发与边缘触发

进程 ↔ 本端内核

CLOSE_WAIT

进程 ↔ 本端内核

半关闭

进程 ↔ 本端内核(方向粒度)

TIME_WAIT

本端内核为对端副本兜底

三次握手与四次挥手

建立与拆除两端副本

半开连接

本端内核 ↔ 对端内核

可靠性机制

本端内核 ↔ 对端内核

MTU 与 MSS

两端副本对路径的假设

收发包路径

副本的物理载体

UDP

放弃前两份,只留你自己那份

边缘触发在内核里只是少了一个 else if

先问一个具体问题:ET 模式凭什么能省掉重复通知。多数讲解会说它「只在状态变化的边沿通知一次」,这句话描述了行为,没有解释机制,而机制在 fs/eventpoll.c 里,一共七行。

epoll 的就绪队列叫 ep->rdllist。网卡收到数据、协议栈把 skb 挂进 socket 接收队列之后,会调用 sk_data_ready,最终走到 epoll 注册的回调 ep_poll_callback(),那里干的事就一件:

C

/* fs/eventpoll.c, Linux 6.6, ep_poll_callback() */
} else if (!ep_is_linked(epi)) {
/* In the usual case, add event to ready list. */
if (list_add_tail_lockless(&epi->rdllink, &ep->rdllist))
ep_pm_stay_awake_rcu(epi);
}

注意 !ep_is_linked(epi) 这个判断:已经在就绪队列里的 epitem 不会被重复挂。所以「数据到达」这个事件,在内核侧本来就只会入队一次,跟你用 LT 还是 ET 无关。

差别出现在出队的那一侧。ep_send_events() 把就绪事件拷给用户态之后,要决定这个 epitem 是留下还是丢掉:

C

/* fs/eventpoll.c, Linux 6.6, ep_send_events() */
if (epi->event.events & EPOLLONESHOT)
epi->event.events &= EP_PRIVATE_BITS;
else if (!(epi->event.events & EPOLLET)) {
/*
* If this file has been added with Level
* Trigger mode, we need to insert back inside
* the ready list, so that the next call to
* epoll_wait() will check again the events
* availability.
*/
list_add_tail(&epi->rdllink, &ep->rdllist);
ep_pm_stay_awake(epi);
}

就这些。LT 是一个额外动作:把刚刚报告过的 epitem 重新塞回就绪队列,让下一次 epoll_wait() 再查一遍当前状态。ET 是这个动作的缺席。 内核里根本没有一段代码专门负责「检测边沿」,所谓边沿,是重新入队这个动作被跳过之后的观感。

把它翻译成副本语言,两种模式的语义就精确了。

LT 是状态同步:每次 epoll_wait() 返回时,内核把「此刻接收队列非空」这个当前状态重新告诉你一遍。你漏读了也没关系,下一轮它还在。代价是每一轮都要为每个就绪 fd 重新调一次 ep_item_poll() 去查状态,就绪 fd 多的时候这是实打实的开销——我那次改回 LT 之后每核 si 涨的六到八个百分点,出处就在这里,症状三归位。

ET 是增量同步:内核只在 ep_poll_callback() 被触发的那一刻推给你一次差分。差分推完,内核认为你已知晓,它的记账里这条 fd 就是干净的。你如果没把差分应用完整,内核不会重发,因为在它看来重发已经发生过了。

这个差别有一个很反直觉的推论:ET 并不比 LT「更快通知」,两者的通知时机完全一样,都在 ep_poll_callback() 那一刻。ET 省下的是 LT 每轮重查状态的那部分开销,代价是把「读干净」的责任从内核转移给了你。就绪 fd 数量少的时候,这笔转移换不来什么;每轮就绪几万个 fd 的接入层,它值。

顺带说清一个常见误解:EPOLLET 不改变可读的判定条件,它只改变 epitem 的去留。有人以为 ET 下「缓冲区从空变非空才通知」,其实是「非空期间的第二次以后不再入队」,因为 ep_is_linked 已经拦住了。缓冲区里还剩数据、你又读走一部分之后新数据到达,ep_poll_callback 照样触发,照样通知——只要那时 epitem 已经出队了。

把 ET 写错的那个循环长什么样

上一节的结论落到代码上就一句:ET 下每次拿到事件,必须一直读到内核明确告诉你「没有了」。而内核表达「没有了」的方式只有一个,read 返回 -1 且 errno == EAGAIN。

写错的版本长这样,它在压测里能跑,在生产里会漏:

C++

// 会漏事件的 ET 读循环
if (ev.events & EPOLLIN) {
char buf[65536];
ssize_t n = ::read(fd, buf, sizeof buf); // 只读一次
if (n > 0) on_data(buf, n);
else if (n == 0) on_peer_closed(fd);
}

read 是有上界的。对端一次 write 了 100 KB,内核接收队列里躺着 100 KB,这段代码读走 64 KB 就返回了。剩下的 36 KB 还在队列里,而 epitem 已经出队,内核记账为已通知。此后如果对端不再发任何数据,这 36 KB 就永远躺在那里——它会一直躺到连接被关掉。

更隐蔽的是 accept 侧。listen fd 在 ET 下同样只入队一次,而 accept 队列里可能一次积了几十个连接:

C++

// 同样会漏,且漏掉的是整条连接
if (ev.events & EPOLLIN) {
int cfd = ::accept4(lfd, nullptr, nullptr, SOCK_NONBLOCK);
if (cfd >= 0) add_to_epoll(cfd);
}

这段代码在一次事件里只摘走队首那一个。剩下的连接留在 accept 队列里,客户端侧的三次握手已经完成了——注意这一点,客户端的 connect() 早就返回成功,它认为连接可用,开始发数据;服务端这边内核也认为连接是 ESTABLISHED,只有你的进程完全不知道这条连接的存在。第三份副本落后于前两份,落后时长取决于下一次有新连接到来的时刻。

正确的写法是把「读干净」写成显式循环,出口是 EAGAIN:

C++

// ET 下的收敛循环:唯一合法的出口是 EAGAIN
char buf[65536];
while (true) {
ssize_t n = ::read(fd, buf, sizeof buf);
if (n > 0) { on_data(buf, n); continue; }
if (n == 0) { on_peer_fin(fd); break; } // 收到 FIN
if (errno == EINTR) continue; // 信号打断,重来
if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 真的读干净了
on_error(fd, errno); break;
}

三个出口的语义各不相同,混淆任何两个都会出事。n == 0 是对端发了 FIN,这是状态变更;EAGAIN 是暂时没数据,这是流量事件;其他 errno 是连接坏了。我那台网关上的成因就是缺了这个循环:一次事件只读一次,数据读走了,紧跟在数据后面的那个 FIN 留在队列里没被取出,n == 0 这条分支一次都没走到过。症状一归位。改成 LT 之所以短暂管用,是因为内核每一轮都把这条 fd 重新塞回就绪队列,那个 FIN 迟早会在某一轮被读到——它没修 bug,只是让内核替我把循环转完了。

还有一种更难的变体:循环写对了,但 n == 0 被并进了 EAGAIN 的分支当成「暂时没数据」。这一种 LT 也救不了,因为无论内核通知多少次,那条 fd 上永远等不到别的东西。它的曲线跟前一种一模一样,靠 ss 分不开,只能读代码。

这里还有一个必须配套的约束:ET 循环必须搭配非阻塞 fd。阻塞 fd 上这个循环里收尾那次 read 会睡进去,把整个事件循环线程挂住。所以 accept4() 直接带上 SOCK_NONBLOCK;先 accept 再 fcntl 设置的写法留了一个窗口,从 accept 返回到设置成功之间那条 fd 是阻塞的,窗口很窄,但它在。

写循环还有第二个代价,很少有人提:一个疯狂发数据的连接会把这个循环钉死,事件循环里其他 fd 拿不到调度。所以生产实现通常给循环加一个配额,读满 N 次就主动让出,把这条 fd 记进一个「待续」列表下一轮优先处理。这等于在应用层重新实现了一遍 LT,而这恰好说明了一件事:ET 与 LT 的分界线落在「谁来记住『还没读完』」这个责任上。内核记,就是 LT,成本是每轮重查;你记,就是 ET,成本是这段循环和它的边界条件。

我的偏好是接入层用 ET、业务层用 LT。接入层的 fd 数量大、每个连接的数据量小,LT 的重查成本随 fd 数线性增长;业务层的 fd 少、单次数据量大、逻辑复杂,把「读干净」的责任交给内核更省心。这个偏好在每轮就绪 fd 数低于几百的场景下站不住脚——那时候两者的差别在噪声里。

收到 FIN 之后,那条连接的生命周期归你管

被动关闭一侧的状态迁移只有三跳:ESTABLISHED 收到对端的 FIN,进 CLOSE_WAIT;你调 close(),发出自己的 FIN,进 LAST_ACK;收到对端的 ACK,进 CLOSED。

三跳里,只有中间那一跳需要你的代码。前后两跳都是内核收到报文自动完成的。所以 CLOSE_WAIT 的语义可以精确到一句话:内核已经确知对端不再发送数据,它正在等你的进程承认这件事。

先把一个常见的困惑处理掉:为什么 CLOSE_WAIT 没有超时,而对称位置上的 FIN_WAIT_2 有。

FIN_WAIT_2 是主动关闭方发完 FIN、收到 ACK 之后的等待态,它等的是对端应用调 close()。include/net/tcp.h 里的常量给了它一个上限:

C

/* include/net/tcp.h, Linux 6.6 */
#define TCP_TIMEWAIT_LEN (60*HZ) /* how long to wait to destroy TIME-WAIT
* state, about 60 seconds */
#define TCP_FIN_TIMEOUT TCP_TIMEWAIT_LEN

tcp_fin_timeout 的文档写得很克制,关键限定词在开头:「The length of time an orphaned(no longer referenced by any application)connection will remain in the FIN_WAIT_2 state before it is aborted at the local end. Default: 60 seconds」。孤儿连接才有超时。那条连接的 fd 已经被应用释放了,内核成了它唯一的持有者,于是内核有权处置它。

CLOSE_WAIT 的情况正相反:fd 还在你手里。TCP 允许半关闭——对端说「我发完了」,完全不等于「我不收了」。它可能正等着你把计算结果写回去。内核如果给 CLOSE_WAIT 加一个超时,等于替你决定了「这个方向也该关」,而这个决定标准里没有授权给它。CLOSE_WAIT 永不消退是语义的必然结果,把它当成内核的缺陷会一路排查到错的地方。

半关闭的两个接口值得分清楚,它们的对称性是假的:

C++

::shutdown(fd, SHUT_WR); // 真的发出 FIN,对端 read 会返回 0
::shutdown(fd, SHUT_RD); // 本地标记,不发任何报文
::close(fd); // 引用计数归零时才发 FIN

SHUT_WR 是一个协议动作,它把「我不再发送」这条信息同步给了对端内核。SHUT_RD 只是本地把读方向标死,后续 read 返回 0,线上什么都没发生,对端毫不知情。close() 则受引用计数约束:fork() 之后父子进程各持一份、或者 dup() 过的 fd,其中一个 close() 不会发 FIN。我排查过一次 CLOSE_WAIT 堆积,代码里 close() 调得规规矩矩,成因是一个早期版本留下的 dup(),另一份 fd 存在一个再也没被访问过的 map 里。

最容易被漏掉的是 close() 的另一条分支:它不一定发 FIN。net/ipv4/tcp.c 的 __tcp_close() 开头会把接收队列排空并计算未读字节数:

C

/* net/ipv4/tcp.c, Linux 6.6, __tcp_close() */
while ((skb = __skb_dequeue(&sk->sk_receive_queue)) != NULL) {
u32 len = TCP_SKB_CB(skb)->end_seq – TCP_SKB_CB(skb)->seq;

if (TCP_SKB_CB(skb)->tcp_flags & TCPHDR_FIN)
len–;
data_was_unread += len;
__kfree_skb(skb);
}

如果 data_was_unread 大于零,走的是另一条路,注释里连带记了一段 1990 年代的故障现场:

C

/* As outlined in RFC 2525, section 2.17, we send a RST here because
* data was lost. To witness the awful effects of the old behavior of
* always doing a FIN, run an older 2.1.x kernel or 2.0.x, start a bulk
* GET in an FTP client, suspend the process, wait for the client to
* advertise a zero window, then kill -9 the FTP client, wheee…
*/

紧接着是 tcp_set_state(sk, TCP_CLOSE) 加 tcp_send_active_reset(),并且计数到 LINUX_MIB_TCPABORTONCLOSE。这个计数器在 netstat -s 的 TcpExt 段里显示成 connections reset due to early user close,它是排查「对端偶发 ECONNRESET」时第一个该看的数。相邻那个 connections reset due to unexpected data 是 TCPAbortOnData,记的是另一件事——连接已经在关闭流程里(FIN_WAIT 状态)又收到了数据,两个数别看混。

推论对写服务端的人很实际:你的进程在关连接之前有没有把接收队列读干净,决定了对端收到的是 FIN 还是 RST。 收到 FIN,对端能完成正常的关闭流程,在途数据不会丢;收到 RST,对端内核会丢弃自己接收队列里尚未交付给应用的数据,read 直接返回 ECONNRESET。一个业务侧「我已经处理完了主动断开」的优化,配上没读干净的接收队列,表现就是对端零星丢请求,而抓包看两端时序完全正常——RST 是合法报文,它只是把数据带走了。

SO_LINGER 设成 l_onoff = 1, l_linger = 0 是显式走这条路,内核里那段判断紧跟在 data_was_unread 之后:

C

} else if (sock_flag(sk, SOCK_LINGER) && !sk->sk_lingertime) {
/* Check zero linger _after_ checking for unread data. */
sk->sk_prot->disconnect(sk, 0);
NET_INC_STATS(sock_net(sk), LINUX_MIB_TCPABORTONDATA);
}

注释里那句 Check zero linger _after_ checking for unread data 说明了优先级:有未读数据时按 RFC 2525 发 RST,两条路殊途同归,但计数器不同,一个记 TCPABORTONCLOSE,一个记 TCPABORTONDATA。排查时这两个数能把责任分清楚,值得单独盯。

TIME_WAIT 那六十秒,内核替对端留着一个能回 ACK 的人

把 CLOSE_WAIT 和 TIME_WAIT 摆在一起看,它们的形状极像:都是「连接逻辑上已经结束,内核仍在内存里留着一份状态」。等的对象却完全相反,而这个相反决定了两种堆积的排查方向截然不同。

CLOSE_WAIT 等的是本机的进程。TIME_WAIT 等的是对端内核的重传。

内核注释把第二件事讲得比任何教科书都直接。net/ipv4/tcp_minisocks.c 里 tcp_timewait_state_process() 上方那段:

text

Main purpose of TIME-WAIT state is to close connection
gracefully, when one of ends sits in LAST-ACK or
CLOSING retransmitting FIN (and, probably, tail of
data) and one or more our ACKs are lost.

Second purpose of TIME-WAIT is catching old duplicate
segments. Well, certainly it is pure paranoia, but if
we load TIME-WAIT with this semantics, we MUST NOT
kill TIME-WAIT state with RSTs.

多数中文资料讲 TIME_WAIT 时,开篇就是「防止旧连接的报文干扰新连接」。那是内核作者标注的第二目的,而且他在旁边写了 pure paranoia。第一目的是别的东西:对端此刻可能卡在 LAST_ACK 里反复重传 FIN,我这边回的那个收尾 ACK 一旦丢了,得有人在这个四元组上再回一次。TIME_WAIT 是为对端那份状态副本留的兜底人。 它跟本端的业务毫无关系,本端付出的是内存和端口占用,收益全部归对端。

理解了这一点,几个长年被当成「调参技巧」的东西就有了判据。

为什么是六十秒。 上面那个 TCP_TIMEWAIT_LEN 是一个编译期常量,tcp_time_wait() 里对进入 TIME_WAIT 的连接直接赋值 timeo = TCP_TIMEWAIT_LEN。教科书说 2MSL、MSL 取决于实现,Linux 把它钉死在六十秒,/proc 下没有对应的可调项。tcp_fin_timeout 常被误当成 TIME_WAIT 的时长,它管的是 FIN_WAIT_2,只是恰好共用了同一个常量。这一条在生产上很实际:想缩短 TIME_WAIT,改内核参数这条路走不通,只能改连接模式或者重编内核。

为什么主动关闭方才进 TIME_WAIT。 谁先发 FIN,谁就要为那个终结性的 ACK 兜底。所以服务端主动关短连接,TIME_WAIT 全堆在服务端,每条占一个四元组;反过来让客户端主动关,压力分摊到成千上万个客户端上,每个都只有几条。这是我见过的短连接架构里最值钱的一条设计约束,它决定了服务端要不要为端口耗尽做准备。

tcp_tw_reuse 的默认值变过。 现在的文档写的是:「0 – disable,1 – global enable,2 – enable for loopback traffic only. Default: 2」。很多流传的调优脚本里写着 net.ipv4.tcp_tw_reuse = 1,那是在默认值还是 0 的年代写的。现在的默认值已经覆盖了绝大多数真实痛点——本机回环上的短连接——而把它全局打开涉及跨机的旧报文风险,内核选择了保守。另外,tcp_tw_recycle 在 4.12 之后已经从内核里删掉了,还在建议开这个参数的文章,年份可以直接看出来。

tcp_max_tw_buckets 是个硬截断。 文档原话:「Maximal number of timewait sockets held by system simultaneously. If this number is exceeded time-wait socket is immediately destroyed and warning is printed.」超了就直接销毁并打日志。把它调小确实能压住 TIME_WAIT 计数,代价是你主动放弃了给对端兜底的义务,换来的是对端那侧偶发的 Connection reset。这个开关很少是对的选择,除非你已经确认对端的重传路径不存在。

SO_REUSEADDR 解决的是另一个问题。 它让 bind() 能绑上一个仍处在 TIME_WAIT 的本地地址,作用范围是本进程的监听端口,跟连接数量无关。服务端重启时的 Address already in use,绝大多数是这个开关的事。它跟 tcp_tw_reuse 名字像,管的东西完全不同:一个管 bind,一个管发起新连接时能否复用处在 TIME_WAIT 的四元组。

回到症状。那台网关上的 TIME_WAIT 也在涨,但它涨得平稳、有上界,跟 CLOSE_WAIT 的单调爬升是两条曲线。TIME_WAIT 堆积是连接模式的读数,CLOSE_WAIT 堆积是应用代码的读数,把它们放进同一张告警里看,是我当时判断跑偏的原因之一。

建立要三步而拆除要四步,差的是那个方向

握手与挥手的次数差,几乎每份资料都会讲,讲法通常是「因为 FIN 和 ACK 不能合并」。这是结论,不是理由。

回到副本模型推一遍。建立连接要同步的东西是两个方向各自的起始序号。A 告诉 B 自己的 ISN,B 确认;B 告诉 A 自己的 ISN,A 确认。四个动作。中间那两个动作——B 对 A 的确认、B 对自己 ISN 的宣告——发生在同一时刻、同一台机器上、面向同一个对端,没有任何理由分成两个包,于是合并成 SYN+ACK,四步压成三步。

拆除连接要同步的东西是两个方向各自的结束。A 说「我发完了」,B 确认;B 说「我发完了」,A 确认。同样是四个动作。中间那两个动作却不在同一时刻:B 收到 A 的 FIN 时,B 的应用可能还有半个响应没写完。内核收到 FIN 必须立刻确认,否则 A 会重传;而「我也发完了」这件事要等 B 的应用调 close(),可能是几微秒后,也可能是几分钟后。中间那段无法预测的间隔,就是 CLOSE_WAIT 状态本身。 合并不了的原因在这里,跟报文格式无关。

当 B 的应用确实没有任何待发数据、且 delayed ACK 还没触发时,Linux 会把 FIN 和 ACK 合并,抓包能看到三次挥手。它是优化的结果,不是协议规定的形态。

半开连接是同一枚硬币的另一面。RFC 9293 §3.5.1 的措辞前面引过,without the knowledge of the other。标准接着写:这类连接「will automatically become reset if an attempt is made to send data in either direction」——只要任一方向尝试发数据,它就会被自动重置。恢复机制是有的,触发条件是发数据。 一条空闲的半开连接,可以在两端的内存里各躺各的,躺到进程退出。

建立侧还有一个被广泛讲错的地方:listen() 的 backlog 到底是什么。内核维护的是两个队列。

text

SYN 到达


┌────────────────┐ 收到 ACK ┌────────────────┐
│ SYN 队列 │ ──────────► │ accept 队列 │
│ (SYN_RECV) │ │ (ESTABLISHED) │
│ request_sock │ │ 等你 accept() │
└────────────────┘ └────────────────┘
上限 上限
tcp_max_syn_backlog min(backlog, somaxconn)

SYN 队列放的是半连接:SYN 收到了,SYN+ACK 发出去了,第三个包还没回来。上限由 tcp_max_syn_backlog 控制,文档说明它是 per-listener 的,且会随内存自动放大。溢出时若 tcp_syncookies 打开(默认就是 1),内核用 syncookie 兜住,把状态编码进 ISN 里,不占内存。

accept 队列放的是已经完成三次握手、等着你 accept() 的全连接,上限是 min(listen 的 backlog 参数, net.core.somaxconn)。SOMAXCONN 这个常量在 Linux 5.4 里从 128 提到了 4096,很多老框架的默认 backlog 写死成 128,就是那个年代的产物。

accept 队列满了会发生什么,是这一节最值钱的一个事实。默认行为是丢弃——内核把客户端发来的第三次握手 ACK 直接丢掉,什么都不回。客户端那侧的状态副本已经是 ESTABLISHED 了,connect() 早就返回成功,它开始发数据;服务端这边内核压根没建立这条连接。三份副本在这里裂成了两个不相容的世界,而这个裂缝是默认配置下的正常行为,日志里一个字都没有。

服务端侧唯一的痕迹是 netstat -s 里的两个计数器,listen queue of a socket overflowed 和 SYNs to LISTEN sockets dropped。它们是排查「客户端偶发连接超时而服务端毫无异常」的入口。

tcp_abort_on_overflow 把行为改成回 RST。文档对它的态度很明确:「Enable this option only if you are really sure that listening daemon cannot be tuned to accept connections faster.」默认关着的理由也写了——「if overflow occurred due to a burst, connection will recover」,突发流量下丢一个 ACK,客户端重传就恢复了,而 RST 不可恢复。要不要开,取决于你更怕慢还是更怕错:开了之后客户端立刻失败并可以重试别的节点,关着则是让它在超时里耗着。我倾向于开,前提是客户端侧有像样的重试与摘除逻辑;没有的话,开它只是把一种故障换成另一种。

看队列深度不用抓包。ss -lnt 在 LISTEN 状态的 socket 上,两个字段的语义跟 ESTABLISHED 时完全不同:

text

$ ss -lnt
State Recv-Q Send-Q Local Address:Port
LISTEN 0 4096 0.0.0.0:8080
LISTEN 129 128 0.0.0.0:9090

Recv-Q 是当前 accept 队列里积压的连接数,Send-Q 是这个队列的上限。第二行那种 Recv-Q 顶着 Send-Q 的状态,说明应用 accept 的速度已经跟不上了,再往后就是静默丢弃。这一条我放在监控里,比看 CPU 有用得多。

半开连接被发现的时刻,是一个可以算出来的数

前面说过,半开连接靠发数据触发恢复。这句话对写服务端的人来说信息量不够——真正要回答的是多久。这个数可以算,而且算出来的三个量级差得很远,正好对应三种排障结论。

第一种情况,对端的机器还活着,只是那条连接的状态没了:进程重启、容器换代、协议栈重置。你发过去一个带数据的包,对端内核在自己的连接表里查不到这个四元组,按标准回一个 RST。你的下一次 read 或 write 立刻拿到 ECONNRESET。延迟等于一个 RTT。 这是最幸运的情况,也是最常见的。

第二种情况,对端整台机器掉线,或者中间有设备把包静默丢掉。防火墙老化掉了会话表是典型成因,很多设备的 TCP 会话默认老化时间是三到五分钟,且过期后不发 RST。你的包发出去,没有任何回应。内核开始重传,按指数退避,直到 tcp_retries2 用完。文档把这个数算好了:

This value influences the timeout of an alive TCP connection, when RTO retransmissions remain unacknowledged. Given a value of N, a hypothetical TCP connection following exponential backoff with an initial RTO of TCP_RTO_MIN would retransmit N times before killing the connection at the (N+1)th RTO. The default value of 15 yields a hypothetical timeout of 924.6 seconds and is a lower bound for the effective timeout.

九百二十四点六秒,约十五分半,而且文档强调这是下界。这就是那种「服务端说连接还在,客户端早就走了,十五分钟后突然一批错误」的成因。这个数可以通过 TCP_USER_TIMEOUT 这个 socket 选项直接压到你想要的毫秒数,它比调全局 tcp_retries2 精确得多,因为它是 per-socket 的。写长连接中间件的话,这个选项比 keepalive 更该先设。

第三种情况,你什么都不发。链路空闲,两端各自躺着。这时候唯一的发现机制是 SO_KEEPALIVE,而它的默认时间参数是这样的:

C

/* include/net/tcp.h, Linux 6.6 */
#define TCP_KEEPALIVE_TIME (120*60*HZ) /* two hours */
#define TCP_KEEPALIVE_PROBES 9 /* Max of 9 keepalive probes */
#define TCP_KEEPALIVE_INTVL (75*HZ)

两小时空闲之后才发第一个探测包,然后每七十五秒一次,九次都没回应才判死。加起来是 7200 加 675,等于 7875 秒,两小时十一分钟十五秒。

我当时给那批连接开了 SO_KEEPALIVE,第二天看到确实清掉了一批,就把它记成了一条有效措施。回过头看,那是我第二个判断错误:清掉的时间点集中在两小时之后,说明生效的是这条默认路径,而故障连接的产生速率远高于每两小时清一次的速度。一个把清理周期设成两小时的机制,去治一个每分钟都在产生新实例的现象,看上去有效只是因为观察窗口够长。 症状四归位。

三个数放在一起,结论就出来了:

触发条件

发现延迟

决定它的参数

发数据,对端在线

一个 RTT

发数据,对端不可达

≥ 924.6 秒

tcp_retries2 / TCP_USER_TIMEOUT

空闲,开了 keepalive

7875 秒

三个 tcp_keepalive_*

空闲,未开 keepalive

永不

内核给的默认值全都是为长连接的存活优化的,而分布式系统要的是及时发现死亡,两者的方向相反。所以生产上真正靠得住的是应用层心跳:周期可以设成秒级,而且它探测的是应用是否还能处理请求,内核 keepalive 只能探测协议栈是否还在应答。一个 GC 停顿三十秒的进程,内核 keepalive 全程正常。

write 返回成功,只证明数据进了本机的发送队列

把 TCP 可靠性的边界一次说清楚:它保证的是字节流按序、完整、不重复地交付到对端内核的接收队列,止步于此。 对端的应用有没有 read、读了有没有处理成功、处理完有没有落库,TCP 一无所知,也没有任何字段能表达这些。

这条边界正是三副本模型的直接后果。TCP 是本端内核与对端内核之间的同步协议,它同步的是前两份副本。第三份副本——对端的进程——不在它的管辖范围里。

所以 write() 返回一个正数,含义只有一个:这么多字节已经从你的用户态缓冲区拷进了内核的发送队列。此刻它可能还没上网线。

我在一个消息网关上按「write 成功即投递成功」记过投递状态,跑了半年没事,一次机房侧的交换机故障期间集中暴露:那半分钟里 write() 全部成功返回,数据全在 sk_wmem 里排队,连接随后被 tcp_retries2 判死,几万条消息的状态是「已投递」而对端一条都没收到。补救靠的是下游对账,代价是两天。这个错误的形状很典型:把本机的一次内存拷贝,当成了跨机的一次状态同步。

反过来看,为了做到「按序完整交付到对端内核」,TCP 手里的机制有五件,它们各自解决同步链路上的一种失败:

序号与累积确认是基础。每个字节有一个序号,ACK 号的语义是「这个序号之前的字节我全收到了」。累积确认的好处是 ACK 丢一个不要紧,后一个 ACK 覆盖前一个;代价是它表达不了「我收到了 1-1000 和 2001-3000,缺 1001-2000」这种空洞。SACK 选项补的就是这个洞,它让发送方只重传真正缺的那段,而不是从空洞处全部重来。没有 SACK 的年代,一个丢包会让后面所有已经安全到达的数据白跑一趟。

超时重传处理的是 ACK 一直不来。RTO 由 RTT 的滑动估计推出来,内核维护 SRTT 和 RTTVAR 两个量,重传时按指数退避,退到 tcp_retries2 用完为止——上一节那个 924.6 秒就是从这里来的。这里有个容易忽略的细节:重传报文的 RTT 采样是不可信的(收到的 ACK 对应的是原包还是重传包分不清),所以内核按 Karn 算法跳过重传样本,只用退避后的 RTO。

快速重传处理的是「只丢了一个包,但后面的都到了」。接收方每收到一个失序段就立刻回一个重复 ACK,发送方数到三个重复 ACK 就不等 RTO 直接重传。三这个数字是为了区分「乱序」和「真丢」——网络内的轻度乱序通常产生一到两个重复 ACK。

滑动窗口是接收方对发送方的流量控制。接收方在每个 ACK 里带上自己还能收多少(rwnd),发送方发出去但未被确认的数据不能超过它。窗口字段只有 16 位,上限 64KB,现代链路远远不够,靠 SYN 里的窗口缩放选项把它左移最多 14 位。缩放因子只在握手时协商一次,这是又一处「只说一遍的状态」——中间设备如果剥掉了 SYN 里的窗口缩放选项,两端对窗口大小的理解会差几个数量级,表现是吞吐诡异地卡在某个低值上。

rwnd 的实际来源是接收缓冲区的剩余空间,而缓冲区大小由 tcp_rmem 自动调节:

tcp_rmem — min: Default: 4K. default: initial size of receive buffer used by TCP sockets. Default: 131072 bytes. max: Default: between 131072 and 6MB, depending on RAM size.

于是有一条直接的因果链:你的应用读得慢,接收缓冲区被占满,rwnd 缩小,对端的发送窗口被压住,最终对端的 write() 阻塞或返回 EAGAIN。背压是从你的 read 调用一路传回对端应用的,中间经过四次转换,每次转换都有延迟。这也是为什么慢消费者会拖垮上游,而监控上看两端的 CPU 都不高。

rwnd 降到零时,发送方进入零窗口探测:定期发一个字节的探测包问「现在能收了吗」。这个机制是必须的,因为「窗口开了」这条消息本身如果丢了,双方会永久等待——一个只靠对端主动通知的设计,在丢包面前一定会死锁。

拥塞控制是对网络的估计,跟接收方无关。cwnd 和 rwnd 取最小值决定实际发送量。慢启动、拥塞避免、快恢复这套是 Reno 系的骨架,Linux 默认用 CUBIC,把窗口增长写成关于「距离上次丢包的时间」的三次函数,在高带宽长延迟链路上比 Reno 激进得多。BBR 换了一条路,用带宽和 RTT 的乘积估计管道容量,不再把丢包当成拥塞信号——在有随机丢包的无线或跨洋链路上这个差别很大。切换用 net.ipv4.tcp_congestion_control,是少数几个改了立刻能量到效果的参数。

校验和处理的是链路上的位翻转。TCP 校验和是 16 位反码求和,强度很弱,一个 16 位字里翻两位就可能校验通过。现代网卡把它卸载到硬件,ethtool -k 里的 rx-checksumming 就是它。数据完整性真要保证,应用层还得自己有一层,尤其是跨公网长时间传大文件的场景。

五件事凑在一起,得到的仍然只是「对端内核收到了」。跨越第三份副本的确认,只能由应用层自己发,这就是所有 RPC 框架都要有自己的响应报文、所有消息队列都要有业务 ack 的原因。它跟 TCP 可靠不可靠没关系,它是可靠性边界的位置决定的。

MSS 是握手时说的一句话,MTU 是路上每一跳的现实

有一类故障的形状极其固定:连接能建立,小包一切正常,大包一发就卡死。SSH 能登录、能敲命令、能看到提示符,ls 一个大目录就再也没有回音。HTTP 的 GET 正常,POST 一个几 KB 的 body 就超时。

这类故障的根因几乎总是同一个,而它正好是三副本模型在路径这个维度上的投影:两端对「一个包最大能有多大」的假设,跟路径上某一跳的现实对不上。

先把两个量的关系摆清楚。MTU 是链路层一帧能装的载荷上限,以太网默认 1500 字节。MSS 是 TCP 段里数据部分的上限,等于 MTU 减去 IP 头和 TCP 头,无选项的 IPv4 是 1500 减 40 等于 1460。

关键在于它们的同步方式完全不同。MSS 在 SYN 和 SYN+ACK 里各宣告一次,此后再也不重说;而且它的语义是「我能收多大」,不是「我要发多大」。双方各自宣告,各自记下对端的值,发送时取自己那条路径上的估计与对端宣告值的较小者。

MTU 呢,路径上每一跳都有自己的值,中间任何一跳都可能比两端小,而两端在握手时对此一无所知。隧道是最常见的成因:VXLAN 封装吃掉 50 字节,把 1500 的底层 MTU 压成 1450;PPPoE 是 1492;IPsec 视算法在 1400 上下。容器网络里这几乎是常态。

按标准,TCP 发出的 IP 包默认置 DF 位(不允许分片)。中间那一跳发现包太大又不能分片,应当回一个 ICMP Fragmentation Needed(IPv6 是 Packet Too Big),里面带着它的 MTU。发送方收到后更新这条路径的 PMTU 估计,把包切小重发。这套机制叫 PMTUD,它工作时很优雅。

它不工作的时候,就是上面那个故障。整条链路上任何一个防火墙、任何一条安全组规则把 ICMP 全部丢掉,那个 Fragmentation Needed 就到不了发送方。发送方毫不知情,继续按 1460 发,包在中途被静默丢弃,重传,再丢,直到 tcp_retries2 用完。小包能过是因为它们比路径 MTU 小,所以整条链路上的控制流量、握手、短命令全是正常的,这就是为什么这类故障看起来像「网络时好时坏」。

IETF 对这件事的态度在 2020 年正式转了向。RFC 8900 的标题就是 IP Fragmentation Considered Fragile,文档里明确写了 PMTUD 的两个软肋:ICMP PTB 的瞬时丢失会造成瞬时黑洞,持续丢失会造成持续黑洞;而且 ICMP 易于伪造、接收方无法认证,攻击者可以把 PMTU 估计压到极低。它给上层协议的建议是走 PLPMTUD——由分组化层自己发不同大小的探测包并等确认,完全不依赖网络传回 ICMP。

Linux 的实现就是 tcp_mtu_probing,文档写得很直接:

Controls TCP Packetization-Layer Path MTU Discovery. Takes three values: 0 – Disabled; 1 – Disabled by default, enabled when an ICMP black hole detected; 2 – Always enabled, use initial MSS of tcp_base_mss.

值 1 是「平时不开,检测到 ICMP 黑洞就开」,这是绝大多数发行版该设的值,而不少发行版的默认仍是 0。开启后内核会从 tcp_base_mss 起步二分探测,代价是探测期间的几个 RTT 和几次重传。我在跨云专线的场景下把它设成 1,就是为了让那类偶发的大包卡死能自愈,而不是等人去抓包。

另一条路是在网关上做 MSS clamping,握手包一过就把宣告的 MSS 改小:

text

# 把 SYN 里宣告的 MSS 钳到出接口 PMTU
iptables -t mangle -A FORWARD \\
-p tcp –tcp-flags SYN,RST SYN \\
-j TCPMSS –clamp-mss-to-pmtu

这条规则改的是握手时那句话本身,让两端从一开始就把 MSS 记小。它绕开了对 ICMP 的依赖,代价是所有经过这个网关的连接都要为最小的那一跳买单,即便它们实际走的路径更宽。VPN 网关和容器 CNI 里这条规则几乎是标配。

底部还有一个安全相关的下界值得知道。TCP_MIN_MSS 是 88,而 sysctl 里另有一个:

tcp_min_snd_mss — If this ADVMSS option is smaller than tcp_min_snd_mss, it is silently capped to tcp_min_snd_mss. Default : 48 (at least 8 bytes of payload per segment)

这个参数是 2019 年那批 SACK 相关漏洞的产物:恶意对端宣告一个极小的 MSS,能让内核为每个字节维护一个段,重传队列被撑爆。默认 48 保证每段至少有 8 字节载荷。它是一条「协商来的值也得校验」的教训,跟所有信任外部输入的接口一个道理。

UDP 侧的处境更糟:它没有 MSS 协商,也没有连接状态去承载 PMTU 估计。所以基于 UDP 的协议要么自己限死包长(DNS 传统上是 512 字节,EDNS0 才放宽),要么自己做 PLPMTUD——QUIC 就是后者,它把探测写进了协议本身。

一个包从网线走到你的进程,中间换了四次手

前面几节讲的都是状态,这一节讲状态的物理载体。把它放在这里的理由是:第三节说 epoll 的就绪队列由 ep_poll_callback() 填充,而那个回调的起点,在网卡的 DMA 引擎上。

接收方向的完整链路是这样的:

text

网线
│ DMA 写入

RX ring ── 硬中断 ──► napi_schedule()
│ 关掉该队列的中断

NET_RX_SOFTIRQ

net_rx_action()
│ 预算 300 包 / 2000us

驱动 poll() 每实例 64 包

napi_gro_receive() 合并连续段

netif_receive_skb() → ip_rcv()

tcp_v4_rcv()

sk_receive_queue 或 backlog

sk_data_ready() → ep_poll_callback()

ep->rdllist ← epoll_wait 停在这里

几个关键点值得逐个说。

硬中断只做一件事:关中断,然后调度软中断。 网卡驱动在中断处理函数里调 napi_schedule(),内核文档对配套动作的要求很明确——「Drivers should call this function in their interrupt handler」,并且必须「keep the interrupts masked after scheduling the NAPI instance」。关掉中断是重点:高速网卡上每包一个中断会让 CPU 全部时间花在上下文切换上,NAPI 的核心思想就是「第一个包用中断通知,剩下的用轮询取」。

软中断有预算,超了就还回去。 net_rx_action() 一轮的总预算是 netdev_budget,默认 300 个包;时间上限是 netdev_budget_usecs,默认 2000 微秒。单个 NAPI 实例一轮最多取 dev_weight 个,默认 64。文档原话:「Polling will exit when either netdev_budget_usecs have elapsed during the poll cycle or the number of packets processed reaches netdev_budget.」

预算用完而队列里还有包时,net_rx_action() 会重新 raise 一次 NET_RX_SOFTIRQ。软中断在同一次中断返回路径上循环处理的次数也有限,超了就把剩下的工作交给 ksoftirqd 内核线程。top 里 si 高、ksoftirqd/N 吃满一个核,说的就是这条路径压不住了,排查方向是 /proc/net/softnet_stat 的第三列(time_squeeze,预算耗尽的次数),而不是去看应用的 CPU 火焰图。

GRO 是一次批量合并。 napi_gro_receive() 把同一条流的多个连续 TCP 段拼成一个大 skb 再往上送,协议栈上层的处理次数因此下降一个数量级。它是接收侧的性能来源,也是抓包时看到「一个 65535 字节的 TCP 段」的原因——线上并没有这么大的包,那是 GRO 合并后的产物。想看真实线上形态,抓包时得先 ethtool -K eth0 gro off。

协议栈把 skb 挂进哪个队列,取决于此刻用户态有没有持有这个 socket 的锁。 tcp_v4_rcv() 里的分叉是:socket 没被用户锁住,直接走 tcp_v4_do_rcv() 进 sk_receive_queue;被锁住了(你的线程正在 read 或 write 这条 socket),就挂进 sk_backlog,等用户态释放锁时再处理。这解释了一个现象:单线程处理一条大流量连接时,软中断的工作会被推迟到你的系统调用返回那一刻,延迟分布上会出现毛刺。

sk_data_ready 才是 epoll 那条链的起点。 数据进接收队列之后,内核调用 socket 的 sk_data_ready 回调,对于被 epoll 监听的 fd,这个回调最终走到第三节那段 ep_poll_callback(),把 epitem 挂进 ep->rdllist。第三份副本的更新,到这一步才发生。从 DMA 到 rdllist,中间隔着一次硬中断、一次软中断调度、一次可能的预算耗尽重排——所以 epoll 报告的「有数据」,天然就是一个滞后的观测值。 它滞后多久,取决于这条链路上此刻的拥塞程度。

发送方向对称但更短:

text

send()
│ 拷贝进 sk_wmem

tcp_sendmsg() ── 组装 sk_buff

tcp_write_xmit() 受 min(cwnd, rwnd) 与 Nagle 约束

GSO / TSO ── 大段留到网卡再切

ip_queue_xmit() → qdisc (排队规则)

dev_hard_start_xmit() → TX ring → DMA

发送完成中断 → 释放 skb

TSO 和 GSO 是发送侧的对称优化:协议栈把一个远大于 MSS 的段一路带下去,切分交给网卡硬件或者驱动的末端完成,省掉的是上层协议栈的重复执行。它跟上一节的 MSS 有一个交互点——TSO 开着的时候,抓包看到的段长跟线上实际形态同样对不上。

丢包点在这条链上一共有四处,各自有独立的计数器,排查时按顺序看能省很多时间:

位置

溢出原因

怎么看

RX ring

软中断取包跟不上

ethtool -S 的 rx_dropped

backlog / softnet

预算耗尽

/proc/net/softnet_stat

qdisc

发送排队规则满

tc -s qdisc 的 dropped

socket 缓冲区

应用读写跟不上

ss -tanoi 的 Recv-Q / Send-Q

这四个位置对应四种完全不同的处置:调 ring 大小、调预算与 RPS、换 qdisc 或限速、改应用。把它们混成一句「丢包」,就只能靠试。

UDP 把这三份副本合并成了你手里那一份

UDP 常被讲成「TCP 去掉可靠性」,这个说法会让人漏掉最要紧的那件事。UDP 去掉的是状态本身。 内核里没有一个对象代表「这条 UDP 连接」,于是三份副本塌缩成一份——只剩你的进程手里那份认知,而它没有任何东西可以与之分歧。

由此可以一次性推出一串结论,比背特性表可靠得多:UDP 没有 CLOSE_WAIT,因为没有「对端说它发完了」这条信息可传;没有 TIME_WAIT,因为没有需要兜底的终结 ACK;没有半开状态,因为两端从来就没有过共同状态可以失步;没有握手,因为没有序号要同步;也没有 MSS 协商,因为协商结果无处存放。

最能说明问题的是 connect() 在两种 socket 上的差别。同一个系统调用,做的事完全不同:

C++

// TCP:真的发包,三次握手,返回时两端内核各有一份状态
int fd = ::socket(AF_INET, SOCK_STREAM, 0);
::connect(fd, addr, len); // 阻塞直到握手完成或超时

// UDP:一个包都不发,纯本地操作
int fd = ::socket(AF_INET, SOCK_DGRAM, 0);
::connect(fd, addr, len); // 立即返回

UDP 上的 connect() 只做两件本地记账:在 socket 上记下默认目的地址,以后可以直接用 send() 而不必每次带 sendto() 的地址;以及把这个 socket 登记进内核的四元组查找表。

第二件事有一个很少被提起的后果:未 connect 的 UDP socket 收不到 ICMP 错误。 你 sendto() 到一个没人监听的端口,对端回了 ICMP Port Unreachable,内核收到之后要把它关联回某个 socket 才能报给应用,而未 connect 的 socket 在四元组表里匹配不上,于是这个错误被丢弃,你的 recvfrom() 继续阻塞。connect() 过的 UDP socket 则会在下一次系统调用上返回 ECONNREFUSED。写 UDP 客户端时这是个实打实的差别:要不要拿到「对端没开」这个信息,取决于你有没有调那一次本地的 connect()。

字节流与消息边界的差别也归到同一处。UDP 保留边界,一次 sendto() 对应一次 recvfrom();TCP 是字节流,它从来没有承诺过包的概念。所谓「TCP 粘包」这个说法本身就假定了 TCP 有包可粘——它没有。发送侧的 Nagle 算法会合并小段,接收侧的 GRO 会合并连续段,中间任何一跳都可能重新分段。在字节流上做消息切分是应用层从第一天起就该做的事,把它当成一个需要修的现象,方向就错了。

QUIC 是这个模型下最值得看的一个反例。它跑在 UDP 上,却把三份副本重新造了一遍,只是造在用户态:连接状态、序号、重传、拥塞控制全部搬进了应用进程。这么做换到了三样东西——不必等内核升级就能改拥塞算法、流之间的丢包不再互相阻塞、以及最重要的一条,连接标识从四元组换成了 connection ID,于是手机从 Wi-Fi 切到蜂窝、NAT 重新绑定端口,连接都不断。

但半开发现这个问题在 QUIC 里并没有消失,只是换了负责人。QUIC 端点各自维护 idle timeout 并在握手时协商,靠 PING 帧维持活性——这跟 TCP keepalive 是同一套逻辑,区别只在于超时值由应用决定,可以是三十秒而不是两小时。状态副本的不一致是分布式系统的固有属性,换一层协议只能改变谁来发现、多快发现,消不掉它。

选 UDP 的判据因此也简单:你能容忍丢,或者你打算自己实现一套比内核那套更贴合业务的重传与拥塞策略。前者是音视频和监控上报,后者是 QUIC 和各种自研可靠 UDP。介于两者之间、既不能丢又不想自己写状态机的场景,选 UDP 通常是在给自己找活干。

从敲下回车到页面出现,一共建立和拆除了多少份状态

把前面所有结论套到一条真实链路上,问题可以换个问法:从敲下回车到页面出现,这中间一共有多少份独立老化的状态副本,各自由谁负责发现它过期了。

域名解析。 浏览器先查自己的 DNS 缓存和 HSTS 列表,再问系统的 stub resolver,后者读 /etc/hosts、查本机缓存,未命中才发出真正的查询,走 UDP 53。响应超过限长时服务端置 TC 位,客户端改用 TCP 重查一遍——这是 DNS 唯一会用到 TCP 的常规路径,EDNS0 把 UDP 上限从 512 字节放宽之后已经少见很多。

这一站的状态副本是 TTL。权威服务器说这条记录能缓存三百秒,途中每一级递归解析器各自计时,浏览器进程内又缓存一份。切流量时改了权威记录,全网真正生效要等最长的那份副本过期,而它到底有多长,取决于链路上有没有哪一级把 TTL 改小或者忽略了。「改了 DNS 但部分用户还在打老 IP」这类现象,问的就是这份副本。

邻居解析。 目标 IP 不在同网段就发给网关,网关的 MAC 从 ARP 表取。ARP 表项同样有老化时间,同样是一份会过期的副本,同样在切换网关或者主备漂移时会出现「部分机器还在往旧 MAC 发」。

NAT 与防火墙的会话表。 这份副本最容易被忘掉,因为它在你的机器之外。中间设备为每条连接建一个表项,且它有自己的老化时间,通常是分钟级。表项过期后,那条 TCP 连接在两端内核里都还是 ESTABLISHED,包却过不去了——这正是前面讲的第二种半开情形,发现它要等 924.6 秒。长连接服务的心跳周期必须短于链路上最短的那个老化时间,这是心跳周期唯一的硬约束,而多数人是按经验拍的。

TCP 连接本身。 一个 RTT 的握手,此后是本文前八节讲的全部内容。

TLS 会话。 TLS 1.3 把完整握手压到一个 RTT,会话恢复能到 0-RTT。session ticket 是又一份带有效期的副本,它存在客户端手里、由服务端的密钥解密,服务端轮换密钥时所有在途 ticket 一起作废,表现是握手耗时突然整体抬升一个 RTT。0-RTT 数据还有重放风险,标准把它限定在幂等请求上,这个约束是应用要自己保证的。

HTTP 层的连接复用。 keep-alive 让后续请求复用同一条 TCP 连接,省掉握手;HTTP/2 在一条连接上多路复用多个流。这里有个反直觉的地方值得记住:HTTP/2 消除了 HTTP 层的队头阻塞,TCP 层的队头阻塞照旧——一个 TCP 段丢了,它后面所有流的数据都得在接收缓冲区里等重传。QUIC 把每条流的序号独立开来,才真正解决这一层。

服务端的接入。 包到达服务端网卡,走完上一节那条 DMA 到 rdllist 的链路,进 accept 队列,等应用 accept()。这一站的状态副本就是那个队列,ss -lnt 的 Recv-Q 是它的实时读数。

把这些数一遍,一次普通的 HTTPS 请求要穿过至少五份独立老化的状态:DNS 缓存、ARP 表、中间设备的会话表、TCP 连接、TLS 会话。它们的老化时间彼此无关,从秒级到小时级都有,而任何一份过期都表现成同一个现象——偶发的慢,或者偶发的失败。

这就是为什么「偶发」类网络故障难查。现象只有一种,成因有五处,而每一处的诊断入口完全不同。有了这张图,排查就从「试着重启一下」变成了一次有序的排除:先按老化时间从短到长排,再按各自的读数逐个核对。

十分钟验证完你那台机器上的三份副本

回到开头那台网关。用三个问题重新走一遍,答案在十分钟内就能验证完。

问题一:哪两份副本分歧了。 CLOSE_WAIT 单调增长,说明分歧在进程与本端内核之间——内核收到了 FIN 并记进状态机,进程始终没有承认。命令是 ss -tan state close-wait,再用 lsof -p 找出持有那些 fd 的线程。

问题二:谁负责发现。 这一对副本的同步由进程主动发起,内核不会催你。所以没有任何自动机制会消解它,堆积一定持续到 fd 耗尽。这一条解释了为什么改触发模式只是把复现周期拖长——我改的是通知语义,而承认 FIN 这个动作始终缺席。

问题三:多久。 对这一对副本,答案是永不。对另一对——服务端与客户端内核之间那些已经变成半开的连接——答案是 924.6 秒或者 7875 秒,取决于那条连接上还发不发数据。

三问走完,真正要改的四处就浮出来了,而它们跟触发模式都没有直接关系。

ET 的读循环补上以 EAGAIN 收尾的出口,并把 read 返回 0 单独分支处理,调用 close();这一条消掉了 CLOSE_WAIT 的产生源。给所有长连接设 TCP_USER_TIMEOUT,把那个九百多秒压到业务能忍受的量级;这一条把半开连接的存活时间从十五分钟拉到秒级。应用层心跳周期设得短于链路上最短的会话老化时间,而不是依赖两小时才动一次的内核 keepalive。把 ss -lnt 的 Recv-Q 和 netstat -s 里那两个 listen 计数器加进监控,让 accept 队列的静默丢弃变成可见的曲线。

四处改完,那台网关的 CLOSE_WAIT 曲线在两周内一直是平的。真正让它平下来的是第一条,其余三条负责的是让下一次故障在十分钟内可见,而不是三天。

这套问法可以直接搬到你手上任何一次网络异常。ESTABLISHED 是三个地方各存了一份的词,CLOSE_WAIT 是内核在等你,TIME_WAIT 是内核在替对端等,EAGAIN 是内核说「这一刻两份对齐了」。把手上的现象翻译成「哪两份不一致、谁负责发现、多久」,剩下的就是查表。

赞(0)
未经允许不得转载:网硕互联帮助中心 » 别再怪 ET 模式漏事件了!深挖 C++ 接入网关 CLOSE_WAIT 暴涨背后的内核真相!
分享到: 更多 (0)

评论 抢沙发

评论前必须登录!