Linux-33 一个包在内核里的旅程:从网卡 DMA 到 Go 的 Read
上一篇讲 io_uring、sendfile 等接口如何降低应用提交 IO 的控制面成本。这一篇沿反方向追问:当网络字节抵达机器时,内核怎样把它变成某个进程一次 Read 得到的数据?
一个 TCP 包并不是“网卡收到 -> Go goroutine 读取”这样一条直线。它要经过 DMA、网卡队列、中断或轮询、softirq、sk_buff、以太网/IP/TCP 协议处理、路由、socket 接收队列,最后才被 epoll/netpoll 唤醒。建连包还有半连接队列、全连接队列、SYN cookie 与 accept 的另一条状态机。
先看五个问题:
- 网卡为什么先 DMA 到内存,而不能每收到一个包就让 CPU 同步复制和处理?
- 中断为什么在高速网络下会把机器拖垮?NAPI 如何把“中断驱动”变成“有预算的轮询”?
sk_buff为什么同时保存字节、协议头位置、校验状态和路由信息?它为什么既强大又有成本?- IP/TCP 怎样把一个包定位到正确 socket,并把数据交给阻塞的
read、epoll 或 Go goroutine? - SYN backlog、accept queue、SYN flood、SYN cookies 与 TIME_WAIT 分别解决什么历史问题,为什么不能靠简单调大参数或删状态解决?
1. 先画出收包全景
1.1 从电信号到应用字节不是一次函数调用
以一条已建立 TCP 连接上的数据包为例,简化路径是:
网线
-> NIC 收到 Ethernet frame
-> DMA 写入主内存中的 RX buffer
-> 中断通知 / NAPI poll
-> 驱动回收描述符,构造或接管 skb
-> softirq 网络协议栈
-> Ethernet -> IP -> TCP
-> 找到对应 socket
-> 数据进入 socket receive queue
-> 唤醒等待者:epoll / 阻塞 read / Go netpoller
-> goroutine 再次 read,copy 到用户 buffer
其中多处都不是无条件发生:硬件可做 checksum、RSS 分流、GRO 合并;XDP/eBPF、iptables/nftables、路由、隧道、cgroup、L7 代理也可能插入路径。理解主干不是为了背所有函数名,而是为了排查时知道包卡在哪一层。
1.2 控制面、数据面与排队点
收包故障常被模糊描述成“网络慢”,但队列的位置不同,修复方向完全不同:
NIC RX ring 满 -> 硬件/驱动层丢包
NAPI budget 用尽 -> softnet backlog 积压
IP/TCP 被策略丢弃 -> 防火墙、校验、路由、协议状态
socket receive queue 满 -> 应用来不及 read
accept queue 满 -> 应用来不及 accept
Go handler / 下游排队 -> 字节到了进程,却没及时处理
TCP 重传只能补救部分链路丢包,不能替应用无限缓存;调大某一个 buffer 也只是把队列延后,若消费速率始终低于生产速率,最终仍会溢出。
1.3 接收与发送是两条不同的路
本文重点是接收。发送大致反向:应用 write 把数据放入 socket 发送路径,TCP 分段、排队,qdisc 选择出队时机,驱动把 DMA 描述符交给 NIC,网卡发到链路上。发送排队导致的是本地发送缓冲、qdisc 或设备队列积压;接收排队则涉及 RX ring、NAPI、backlog 与 socket receive queue。两边都可能表现为延迟升高,却不能用同一个指标归因。
2. 网卡 DMA:CPU 不应逐字节搬运
2.1 早期 PIO 的问题
如果 CPU 必须主动从设备寄存器逐字节读取网络数据(Programmed IO),每个包都会占用大量指令、总线事务和缓存资源。网络速率提高后,CPU 很快成为搬运工,几乎没有时间运行协议栈和业务。
DMA(Direct Memory Access)把数据搬运交给设备:驱动预先准备可供网卡写入的内存页和描述符,NIC 收到 frame 后直接写入这些物理页,再报告完成。
驱动初始化 RX ring:
RX descriptor 0 -> DMA 地址: page A
RX descriptor 1 -> DMA 地址: page B
RX descriptor 2 -> DMA 地址: page C
NIC 收到包:
DMA frame bytes 到 page A
更新 descriptor 0 状态 / 长度
通知 CPU
CPU 仍要建立 DMA 映射、管理 ring、处理完成状态和协议,但不再执行“把网卡数据复制到内存”的字节循环。
2.2 DMA 不是免费内存访问
设备 DMA 绕开 CPU copy,不意味着没有成本:
- DMA 地址需要受 IOMMU、设备能力和内存布局约束
- cache coherence 平台要维护 CPU 与设备对同一内存的可见性
- ring 太小会在突发流量下耗尽 descriptor;太大增加内存和缓存扫描成本
- 内存页在设备使用期间不能任意回收或复用
- NUMA 上若网卡 DMA 到远端节点内存,协议栈 CPU 访问会付出跨节点延迟
驱动和内核会使用页池、批量回收、每 CPU 缓存等机制,避免每包分配/释放通用内存。高性能收包的基本原则是:让 buffer 生命周期和 CPU/NIC 队列局部化。
2.3 RX ring 满了会发生什么
RX ring 是有限的。网卡收到包而没有可用 descriptor 时,硬件只能丢弃或统计为 overrun;这通常发生在 CPU 没及时回收已完成 descriptor、NAPI 没及时运行,或瞬时流量超过队列容量时。
到达速率 > NAPI/协议栈消费速率
-> 已完成 descriptor 越来越多
-> 可给 NIC 的空 descriptor 越来越少
-> RX ring 满
-> NIC 丢包 / rx_missed_errors 增加
增加 ring 大小能吸收短突发,却会让排队时间上升;持续超载下它只会把丢包推迟。应该同时检查 CPU、IRQ 分布、RSS 队列数、softirq、应用是否反压,以及链路是否被异常流量压满。
3. 中断风暴与 NAPI:为什么收包会从中断切到轮询
3.1 一包一中断为什么会崩
设备完成 DMA 后需要让 CPU 知道。朴素模型是每个包触发一次硬件中断:
100 万包/秒
-> 100 万次 IRQ
-> CPU 不断保存现场、进中断、运行驱动、返回
-> 协议栈和应用反而得不到连续执行时间
中断的价值是空闲时低延迟且无需忙等;问题是高包率下,频繁中断造成上下文切换、缓存扰动和中断处理自身的排队。即使每次 IRQ 很短,数量也会吞掉 CPU。
3.2 NAPI 的两阶段策略
Linux 的 NAPI(New API)把中断与轮询组合:低负载时用中断快速唤醒;检测到收包后,驱动暂时屏蔽或降低该队列 IRQ,并把 poll 工作调度到网络 softirq。softirq 每轮只处理有限预算,未清空则继续调度;清空后重新打开中断。
空闲:
NIC 收包 -> IRQ -> 驱动调度 NAPI
繁忙:
NAPI poll(budget)
-> 批量回收 RX descriptor
-> 处理至多 budget 个包
-> 未清空:继续 softirq poll,暂不依赖每包 IRQ
-> 清空:完成 NAPI,重新启用 IRQ
它避免高负载下每包一次中断,又让空闲连接在第一个包到达时不必持续占 CPU 轮询。
3.3 budget 的公平性意义
NAPI 的 budget 不是任意小优化。若一个繁忙网卡队列可以无限处理包,它会长期占用 softirq CPU,让其他队列、定时器和用户进程饥饿;预算把工作切成批次,调度器才有机会切换任务。
代价是高负载下某些包要等到下一轮 poll,延迟增加。这是吞吐、公平和尾延迟之间的明确取舍,不是内核“没及时处理”。
3.4 hardirq、softirq 与 ksoftirqd
硬中断上下文要短,不能做可能睡眠或耗时很长的完整协议处理。典型流程:
hardirq:
确认网卡队列有完成项
调度 NAPI
尽快返回
NET_RX softirq:
批量 poll 驱动
执行协议栈主要收包逻辑
softirq 长时间跑不完:
交给 ksoftirqd/N
以普通可调度内核线程身份继续
ksoftirqd 的出现防止 softirq 无限占用中断返回路径,但它作为普通调度实体,可能与业务、cgroup quota 和其他线程争 CPU。因此看到 ksoftirqd 高 CPU 并不等于“网卡坏了”,常表示收包工作量超过了当前直接 softirq 的预算或 CPU 供给。
4. 多队列、RSS 与 CPU 局部性
4.1 一个 RX queue 不够用
现代 NIC 通常有多个 RX/TX queue。若所有包都进入一个队列,一个 CPU 必须处理全部中断和 NAPI,即便机器有很多核也会形成单核瓶颈。
RSS(Receive Side Scaling)让 NIC 根据报文的 hash(常基于源/目的 IP 和端口)把流量分到不同接收队列:
flow A hash -> RX queue 0 -> CPU 2
flow B hash -> RX queue 1 -> CPU 6
flow C hash -> RX queue 2 -> CPU 3
同一 TCP 流通常保持在同一队列,利于包序、缓存和 socket 状态局部性;不同连接可并行处理。
4.2 IRQ affinity、RPS 与 RFS
硬件队列和 CPU 的映射还可由多层调整:
| 机制 | 主要位置 | 目的 |
|---|---|---|
| IRQ affinity | 中断路由 | 让某 RX queue 的中断交给指定 CPU |
| RSS | NIC 硬件 | 按 flow hash 分发到多个硬件队列 |
| RPS | 内核软件 | 把收包处理散到更多 CPU |
| RFS | 内核软件 | 尝试把流交给运行其应用的 CPU |
| XPS | 发送侧 | 选择合适 TX queue |
RPS 能帮助硬件队列较少或分布不均的设备,但会引入跨 CPU 的队列和 cache 迁移。RFS 试图提升“处理包的 CPU”与“消费 socket 的 CPU”的亲和性,却依赖工作负载和调度行为。它们不是默认必须打开的调参项。
4.3 NUMA 让“总 CPU 还有空”不够
在双路机器上,NIC 挂在 NUMA node 0,而 IRQ/NAPI 和应用线程被调度到 node 1 时,DMA 页、协议状态与应用读取可能持续跨节点访问。总 CPU 利用率看似不高,单个 socket 的 P99 仍可能变差。
排查高吞吐服务时,要同时看 NIC 队列、IRQ CPU、应用 CPU affinity、NUMA 拓扑和内存 node;把网卡中断、NAPI 和 worker 放在相近节点通常比盲目增加线程更有效。
5. sk_buff:协议栈为什么需要一个“包对象”
5.1 字节之外还有大量上下文
NIC DMA 的是一段 frame 字节,但协议栈还需要知道当前解析到了哪层、来自哪个设备、校验是否已由硬件完成、路由结果、时间戳、mark、是否属于某个 namespace 等。Linux 用 struct sk_buff(通常简称 skb)作为承载包数据及元数据的核心对象。
概念图:
struct sk_buff
-> head/data/tail/end:线性数据区与可用空间
-> mac_header:Ethernet 头位置
-> network_header:IP 头位置
-> transport_header:TCP/UDP 头位置
-> len/data_len:总长度及非线性片段
-> dev、protocol、mark、priority、hash、timestamp
-> destructor、socket ownership、GSO/GRO/checksum flags
协议层通常移动 header offset,而非每过一层就复制整个包:Ethernet 确定 L3 起点,IP 确定 L4 起点,TCP 从 payload 位置读取序号和数据。
5.2 线性与非线性数据
小包可全部放在 skb 的线性区域;大包或聚合包可让 payload 引用 page fragments。这样能减少复制并支持 scatter/gather DMA,但也让访问者必须区分“数据是否连续”。
skb linear head:Ethernet + IP + TCP headers + 一部分 payload
page frags:其余 payload 页引用
frag_list:可能还有链接的 skb
不能假设一个 skb 总能通过普通指针连续访问全部字节。内核提供访问 helper;驱动、BPF 和协议代码需要依据所处 hook 和数据是否 linearized 正确处理。
5.3 skb 的代价与演进
skb 是灵活抽象:任何层都可挂元数据、克隆引用、重定向或排队。代价是每包对象分配、引用计数、cache miss 和复杂生命周期。高 pps 工作负载中,这些固定成本本身会成为热点。
因此内核持续使用 page pool、GRO、批量 API、每 CPU 数据结构、XDP 等降低“每个小包都走完整 skb 生命周期”的成本。优化不是否定 skb,而是把昂贵的通用抽象留给确实需要完整协议处理的包。
6. Ethernet、IP 与 TCP:包怎样找到 socket
6.1 二层先决定 frame 是否属于本机
驱动提交 skb 后,内核依据 Ethernet 类型处理 ARP、IPv4、IPv6、VLAN 等。目的 MAC 不属于本机、广播/组播规则不匹配的 frame 通常不会进入本地 IP 接收;混杂模式、bridge、容器 veth 或抓包会改变可见性。
二层通过后,IP 层校验版本、长度、TTL/hop limit、分片、目的地址和路由策略。目的地址可能是本机地址、容器 namespace 地址、VIP,或需要转发的地址;防火墙/netfilter hook、策略路由、隧道解封装也可能在此改变走向。
6.2 四元组是 TCP 的主要身份
TCP 建立后,一个连接通常由四元组定位:
源 IP、源端口、目的 IP、目的端口
内核在协议控制块与哈希表中查找匹配 socket。监听 socket 主要按本地地址/端口匹配,已建立 socket 则按更精确的四元组匹配,因此同一服务端口可以同时服务大量客户端连接。
client 10.0.0.8:50123 -> server 10.0.0.10:443
client 10.0.0.9:50123 -> server 10.0.0.10:443
目的端口相同,但源地址不同:对应两个已建立 socket
6.3 TCP 不按包交付,而按有序字节流交付
TCP payload 到达后,TCP 层不能立刻把“这个包”原样交给应用。它要验证序号、窗口、校验、状态机,处理乱序、重复包、重传、FIN/RST;只有连续可交付字节才进入接收队列。
已收到 seq=1000..1999
期待 seq=2000
到达 seq=3000..3999
-> 先进入乱序队列
到达 seq=2000..2999
-> 现在 2000..3999 连续
-> 合并为可供应用读取的字节流
所以抓包看见数据到达不等于应用已经 Read 到;可能仍在乱序队列、等待缺口,或受 socket 内存限制。
6.4 GRO:把多个包合成更大的处理单位
高速网络中,逐个 MTU 包经过完整协议栈会产生很高的每包成本。GRO(Generic Receive Offload)尝试在接收路径将同一流、可安全合并的连续包聚合成更大的 skb,再交协议栈处理。
它降低协议层每包开销和缓存访问次数,但不改变 TCP 的字节流语义。抓包/统计时需要意识到:内核内部看到的 skb 数、网卡看到的 frame 数与应用 Read 的次数不必相同。
7. 从 socket 接收队列到 epoll 和 Go Read
7.1 数据进入哪里
TCP 将可交付 payload 排入 socket 的 receive queue,并更新 sk_rmem_alloc 等接收内存统计。队列从空变为非空或状态变化时,内核唤醒等待者:阻塞在 recv/read 的线程、等待该 fd 的 epoll watcher,或其他内核等待机制。
TCP payload 可交付
-> socket receive queue 入队
-> sk_data_ready callback
-> 唤醒 socket wait queue
-> epoll ready list 得到 EPOLLIN
-> Go runtime netpoller 取到事件
-> goready 等待该 fd 的 goroutine
这与第 31 篇的 epoll 模型衔接:epoll 只知道 socket 可能可读;真正的 read 仍需从 socket receive queue 取字节,并处理 EOF、错误或并发消费造成的 EAGAIN。
7.2 应用不读会发生什么
socket 接收缓冲有上限。应用(或 Go goroutine)迟迟不读时,可用接收窗口缩小;TCP 在 ACK 中向对端通告较小窗口,最终可能为零,对端停止发送,直到窗口重新打开。
应用消费慢
-> receive queue 积压
-> socket rmem 接近上限
-> advertised receive window 缩小
-> 对端被 TCP flow control 限速
这是一种有价值的端到端背压,不是故障自动修复。大量慢连接仍会占住内核 socket 内存;应用需要连接级 deadline、请求体大小限制、并发上限和及时丢弃策略。
7.3 Go 的两次边界
在 Linux 上,Go runtime 把网络 fd 设为 nonblocking。用户 goroutine 调用 Read 时,底层尝试 recv:
socket 已有字节:
内核 copy_to_user -> Go 的 []byte -> Read 返回 n
socket 暂无字节:
recv -> EAGAIN
goroutine park
runtime 通过 epoll 等待
数据到 socket queue 后被唤醒
再次 recv
通常仍存在“内核 socket 缓冲 -> 用户 []byte”这次复制。第 32 篇的零拷贝优化不是让通用 TCP Read 自动没有 copy,而是在特定对象和语义下减少某段搬运。Go 服务的主要现实问题往往是:goroutine 是否及时获得 CPU、handler 是否消费足够快、是否被 TLS/解码/下游阻塞。
8. TCP 建连:半连接队列与 accept queue
8.1 listen 不等于已经建立连接
服务调用:
listen(fd, backlog);
它把 socket 变成监听 socket。客户端三次握手的服务器侧状态大致是:
client server
SYN -------------------------> 收到 SYN,创建 request state
<------------------------- SYN-ACK
ACK -------------------------> 连接完成,进入 accept queue
application accept() --------> 取得新的 connected fd
accept() 不是完成三次握手,而是从内核已完成连接队列中取走一个子 socket。监听 fd 的可读通常表示 accept queue 里至少有连接可取。
8.2 两个队列分别防什么
Linux TCP 细节随版本和配置变化,但理解上应区分两类容量:
SYN / 半连接队列:
收到 SYN、尚未得到最终 ACK 的请求状态
防护对象:大量未完成握手、伪造源地址
accept / 全连接队列:
三次握手已完成、等待应用 accept 的子 socket
防护对象:应用 accept 不及时或瞬时连接突发
它们不能被一个 backlog 数字完全概括。实际有效长度还受 listen(backlog)、somaxconn、tcp_max_syn_backlog、syncookies、内核版本和服务行为影响。排查前应查看本机文档与运行时统计,而不是引用某篇旧文章的固定公式。
8.3 accept queue 满了说明什么
全连接队列满通常表示握手完成速度高于应用 accept 速度:可能是单线程事件循环卡住、CPU quota、fd 耗尽、GC stop、锁竞争,或攻击/突发流量。
完成握手速率 > accept 消费速率
-> accept queue 增长
-> 达上限
-> 新连接无法顺利入队 / 客户端超时或重传
盲目提高 backlog 只能增加可吸收突发时间。若应用持续不 accept,连接仍会失败;根因可能在第 31 篇讨论的 accept loop、惊群、资源上限,也可能在业务线程没有调度机会。
9. SYN flood 与 SYN cookies:不分配状态的代价
9.1 SYN flood 利用的是什么不对称
服务器收到 SYN 后,传统状态机需要保留一些请求状态并发送 SYN-ACK;攻击者可伪造大量源地址或不回 ACK,使半连接队列耗尽。攻击成本低,服务器要付出状态、哈希表、定时器和重传成本。
攻击者:SYN, SYN, SYN, ... 不完成握手
服务器:为每个请求保留 request state,重传 SYN-ACK
-> 半连接队列满
-> 正常客户端 SYN 被延迟或丢弃
这不是应用层限流能完全解决的问题,因为攻击包可能在应用的 accept 之前就占用了 TCP 建连资源。
9.2 SYN cookie 的基本思想
队列压力很大时,服务器可不立即保存完整半连接状态,而把可验证的编码放进 SYN-ACK 的初始序列号;客户端回 ACK 时,服务器根据 ACK 反推出并验证 cookie,再重建必要状态。
收到 SYN
-> server secret + flow tuple + 时间片 -> cookie ISN
-> SYN-ACK(cookie)
-> 不保存或少保存 request state
收到最终 ACK
-> 验证 ack 对应 cookie
-> 合法才创建完整连接状态
它用计算与有限编码空间换状态消耗,保护正常服务免受半连接内存耗尽。
9.3 cookie 不是免费的常态模式
SYN cookie 可编码的信息有限,某些 TCP 选项协商、观测与实现细节会受影响;不同内核版本支持和策略也不同。它主要是压力下的保底机制,而不是“永远开启就更安全”的替代品。
更完整的防护包括上游清洗、边界限速、合理 backlog、服务快速 accept、连接/文件描述符上限、监控 SYN 速率与队列溢出。面对明确的攻击或流量异常,要按组织授权和网络边界策略处置,不应在业务主机上临时试验破坏性网络操作。
10. TIME_WAIT:为什么主动关闭者还要留状态
10.1 四次挥手后的等待
TCP 主动关闭的一方发送最后 ACK 后会进入 TIME_WAIT,保留一段时间(常以 2MSL 的概念解释)。它至少解决两类问题:
- 若最后 ACK 丢失,对端重发 FIN 时仍有人响应
- 让旧连接残留报文在网络中自然过期,避免同一四元组很快复用后被新连接误收
active closer passive closer
FIN ------------------------>
<------------------------ ACK
<------------------------ FIN
ACK ------------------------> TIME_WAIT
若最后 ACK 丢失:对端重发 FIN,TIME_WAIT 状态仍可回答 ACK
10.2 “删 TIME_WAIT”会删掉什么保证
TIME_WAIT 占用内核状态和端口空间,在短连接高峰时容易被误判为异常。但直接用危险的全局参数、RST 或缩短状态来“清理”会破坏重传容错与四元组隔离,可能造成偶发协议错误而且难以复现。
真正要先问:为什么服务有这么多短连接?常见改善是 HTTP keep-alive、HTTP/2/HTTP/3、连接池、上游代理复用、合理的主动关闭策略和端口/负载均衡架构。只有理解主动关闭在哪一侧发生、端口范围与业务故障语义后,才评估内核参数。
10.3 TIME_WAIT 多不一定在本机服务端
TIME_WAIT 属于主动关闭方。若服务端总是主动断开短连接,服务端会多;若客户端池频繁主动断开,客户端或中间代理会多。用 ss 看状态和五元组方向,而不是看到某机器 TIME-WAIT 多就断定其 TCP 配置错误。
11. 排查收包链路:按队列逐层验证
11.1 从接口错误与驱动统计开始
# 接口包、字节、error/drop 计数
ip -s link show dev eth0
# 驱动/NIC 统计项,名称由驱动决定
ethtool -S eth0
# RX/TX channel 数量与硬件 offload
ethtool -l eth0
ethtool -k eth0
rx_missed_errors、ring overrun、驱动 drop 的具体含义依 NIC 驱动而异。先保存基线并看增量;单次累计值不能说明当前故障。不要为了观察而随意关闭 checksum、GRO 或 RSS,改变 offload 会显著改变负载形态。
11.2 看 IRQ、softirq 与 CPU 分布
# 网卡 IRQ 是否集中在某些 CPU
cat /proc/interrupts | grep -i -E 'eth|ens|enp'
# 每 CPU 网络 softirq 累计计数
cat /proc/softirqs | grep -E 'NET_RX|NET_TX'
# softnet 处理、丢弃与 squeezed 等累计指标
cat /proc/net/softnet_stat
# 观察 softirq / ksoftirqd CPU 占用
mpstat -P ALL 1
pidstat -u -p "$(pgrep -d, ksoftirqd)" 1
/proc/net/softnet_stat 是按 CPU 的十六进制字段,字段语义需要结合当前内核文档和工具解码;它适合发现增长趋势,不应靠手工把某一列当永久固定含义。NET_RX 很高也可能是健康高吞吐,关键是是否有 dropped/squeezed 增长和应用 P99 同步恶化。
11.3 看 TCP 队列与重传
# 监听队列、已建立连接与进程归属
ss -ltnp
ss -tinp
# TCP 扩展统计,关注 listen overflow/drop、retrans 等
nstat -az TcpExtTCPReqQFullDrop TcpExtListenOverflows TcpRetransSegs
# 内核 TCP 相关计数(字段随系统不同)
netstat -s | grep -i -E 'listen|overflow|drop|retrans|syncookie'
工具字段和 nstat 名称可能受发行版影响;若某计数不存在,先列出 nstat -az 或查 /proc/net/netstat。队列满、重传高、softirq drop 和应用请求慢要放在同一时间轴比较,单独看到一个 TCP counter 上升不能直接定位到 Go 代码。
11.4 Go 服务的对应检查
# 是否已有 pprof,先看 goroutine 状态和 CPU
curl -o goroutines.txt 'http://127.0.0.1:6060/debug/pprof/goroutine?debug=2'
curl -o cpu.pprof 'http://127.0.0.1:6060/debug/pprof/profile?seconds=30'
go tool pprof -top cpu.pprof
# cgroup CPU 限额/节流可能让 accept 和 read 迟到
cat /sys/fs/cgroup/cpu.stat
cat /sys/fs/cgroup/cpu.max
# fd 数、进程网络 socket 数
ls /proc/"$PID"/fd | wc -l
ls /proc/"$PID"/fdinfo | wc -l
高网络软中断与高 Go CPU 可同时发生:前者在内核收包,后者在 TLS、JSON、业务处理或 GC。若 cgroup 被 throttle,包即使已进入 socket queue,goroutine 也可能没有 CPU 去读;这会反过来缩小 TCP window,形成吞吐与尾延迟一起恶化的循环。
12. 常用命令知识点扩展
| 短参 | 长参 | 英文全称 | 作用 |
|---|---|---|---|
ip -s |
ip --statistics |
statistics | 显示接口收发包、错误与丢弃计数 |
ethtool -S |
ethtool --statistics |
statistics | 显示网卡驱动扩展统计 |
ethtool -l |
ethtool --show-channels |
show channels | 查看/设置 NIC RX/TX 队列 channel |
ethtool -k |
ethtool --show-features |
show features | 查看 checksum、GRO 等 offload 特性 |
ss -l |
ss --listening |
listening | 显示监听 socket |
ss -t |
ss --tcp |
TCP | 仅显示 TCP socket |
ss -i |
ss --info |
internal TCP information | 显示 TCP 内部状态线索 |
nstat -a |
nstat --all |
all | 显示全部网络统计项 |
mpstat -P |
mpstat --processor |
processor | 按 CPU 显示利用率 |
排查配方:
# 每秒记录关键内核网络计数,比较故障窗口的增量
watch -n 1 'date; ip -s link show dev eth0; ss -s'
# 查看某监听端口的队列与所属进程
ss -ltnp '( sport = :8080 )'
# 只看 TCP TIME_WAIT 数量;数量本身不是结论
ss -tan state time-wait | wc -l
13. 面试题
Q:DMA 为什么能提高网络收包性能?
DMA 让 NIC 直接把 frame 写入驱动预先提供的内存页,CPU 不必逐字节从设备寄存器复制数据。CPU 仍负责管理 descriptor、内存映射、完成通知和协议栈;DMA 的价值是把大块搬运从 CPU 指令路径移走,而不是让网络处理完全没有 CPU 成本。
Q:为什么每包一个硬中断会造成性能问题?NAPI 如何解决?
高 pps 下每包中断会造成大量现场切换和 cache 扰动,CPU 忙于响应 IRQ 而无法批量执行协议栈。NAPI 在第一个包到达时用 IRQ 唤醒,随后屏蔽/降低该队列中断并在 NET_RX softirq 中按预算批量 poll,队列清空才恢复中断。它以高负载下可控的轮询延迟换取吞吐与 CPU 效率。
Q:NAPI budget 为什么不能无限大?
无限 drain 一个繁忙队列会让其他 NIC 队列、softirq、定时器和用户进程饥饿。budget 把网络工作分批,使调度和公平性仍能发挥作用;代价是峰值下包可能等待下一轮处理。它是吞吐、公平、P99 的显式权衡。
Q:RSS、RPS、RFS 分别解决什么?
RSS 在 NIC 硬件按 flow hash 将包分给多个 RX queue;IRQ affinity 决定各队列中断由哪个 CPU 接收;RPS 在软件中继续分发处理;RFS 尝试把流交给实际运行 socket 消费者的 CPU。它们都旨在利用多核和改善局部性,但软件重定向也会增加跨 CPU/cache 成本,需按硬件和负载测试。
Q:skb 为什么不能只是一段数据指针?
协议栈需要同时保存 L2/L3/L4 header 的位置、设备、长度、校验/offload 状态、路由、mark、时间戳、socket 所属关系以及非线性 page fragments。skb 让多层能共享数据并附加元数据,但对象分配、引用计数和 cache miss 也是高 pps 下的成本,因此内核用 GRO、page pool 和批量处理缓解。
Q:TCP 为什么不能收到一个包就立刻交给应用?
TCP 向应用提供有序可靠字节流,不是报文流。它必须检查序号和状态,处理乱序、重复、重传、FIN/RST,只有连续字节才可进入可读 receive queue。抓包显示包到达不代表应用已能读到,缺失序号或 socket 内存限制都可能延迟交付。
Q:socket receive queue 满了会怎样?
应用不及时 read 时,TCP 接收缓冲和内核 socket 内存累积,通告给对端的 receive window 逐步缩小,最终为零时对端暂停发送,直到应用消费数据。这是 TCP 流控形成的背压;但大量慢连接仍占内存,应用需要 body 上限、deadline、并发限制和关闭策略。
Q:SYN backlog 和 accept queue 的区别是什么?
半连接队列保存收到 SYN 但尚未完成最后 ACK 的请求状态,主要承受未完成握手与伪造源地址压力;accept queue 保存已完成三次握手、等待应用 accept 的子 socket,主要反映应用 accept 是否及时。两者受不同内核参数和状态机影响,不能只用一个 backlog 数字混为一谈。
Q:SYN cookie 如何缓解 SYN flood?代价是什么?
压力下服务器把可验证信息编码进 SYN-ACK 的序列号,暂不为每个 SYN 保存完整状态;最终 ACK 到达后验证 cookie,合法才创建连接状态。它降低半连接状态耗尽风险,但编码空间有限,某些 TCP 选项和观测语义可能受影响,适合作为压力保护而不是替代容量规划、上游防护和快速 accept。
Q:TIME_WAIT 为什么存在,为什么不能直接删除?
主动关闭方保留 TIME_WAIT,可在最后 ACK 丢失时响应对端重发 FIN,并让旧四元组残留包在网络中失效,避免快速重用同一四元组后污染新连接。粗暴缩短或绕过它会削弱这些协议保证;高 TIME_WAIT 应先从短连接、keep-alive、连接池、主动关闭方向和端口架构分析。
Q:Go goroutine 被网络事件唤醒后为什么还可能读不到数据?
epoll 是 readiness 通知,不保证应用读时仍有字节。另一个 goroutine/路径可能已经读空,连接状态也可能变化,非阻塞 recv 仍可返回 EAGAIN、EOF 或错误。Go runtime 会依据 netpoll 结果唤醒 goroutine,但底层仍需重新尝试 syscall 并按结果处理。
Q:如何区分 NIC 丢包、softirq 积压、listen queue 满和应用处理慢?
NIC/驱动层看 ip -s link、ethtool -S 和 RX ring 相关增量;softirq 看 /proc/softirqs、softnet_stat、CPU 分布;建连看 ss -ltnp、TcpExt listen overflow/drop、syncookie 统计;应用层看 CPU、cgroup throttling、fd 数、goroutine/pprof、handler 与下游延迟。需要把它们与同一故障时间窗的重传、P99 和流量变化关联,而不是依单一计数下结论。
小结
- NIC 用 DMA 将包直接写入预先准备的内存,避免 CPU 逐字节从设备搬运;RX ring 仍是有限队列,持续过载最终会丢包
- 中断适合空闲时低延迟通知,高 pps 下每包 IRQ 会造成中断风暴;NAPI 用 IRQ 唤醒、预算轮询批量处理,在吞吐、公平和延迟间折中
- RSS、IRQ affinity、RPS/RFS 与 NUMA 决定包在哪个 CPU 被处理;错误分布会让多核机器出现单核软中断瓶颈和跨节点延迟
- skb 统一承载包字节、头部偏移、设备、校验、路由及非线性页引用;灵活性带来每包对象成本,GRO/page pool 等用于摊销它
- TCP 按四元组找到 socket,但只把连续的有序字节交付 receive queue;socket 队列与 TCP window 将应用消费速度反压到对端
- epoll 和 Go netpoll 只在 socket 变为可读时安排工作,真正的
Read仍要从内核队列复制数据并处理 EAGAIN、EOF 与错误 - 半连接队列承受未完成握手,accept queue 承受应用来不及 accept;SYN cookie 是耗尽压力下用计算换状态的保护机制
- TIME_WAIT 是重传容错和旧报文隔离的一部分;短连接架构问题不能靠粗暴删除协议状态解决
- 排查要从 NIC/driver、IRQ/softirq、TCP 队列、socket 内存到 Go CPU/handler 按队列逐层验证
下一篇讲 socket API 与 Go 网络编程 —— bind、listen、accept、connect、recv、send 的语义边界,阻塞与 deadline 如何映射到 Go 的 net 包,以及 socket option 为什么经常是系统设计取舍而不是“万能调优项”。
xingliuhua