从网线到策略引擎:Linux 网络收包全路径深度解析
本文以一个真实的加密货币交易系统为背景,系统性讲解 Linux 下一个网络数据包从物理网卡到达用户态应用的完整路径,并结合 AWS Graviton (ARM64) + ENA 网卡 + 192 核环境的实际 IRQ 数据进行分析。
一、全景概览 #
一个数据包从交易所服务器发出,到你的策略引擎处理完毕,经历了以下七个阶段:
交易所服务器
│
▼
┌─────────────────────────────────────────────────────────┐
│ 阶段 1:物理层 & NIC 硬件 │
│ 网线 → PHY → MAC → On-Chip SRAM暂存 → RSS分流 │
│ → DMA 写入主存 Ring Buffer │
├─────────────────────────────────────────────────────────┤
│ 阶段 2:硬中断 (Hard IRQ) │
│ NIC 触发中断 → CPU 响应 → 最小化处理后调度 softirq │
├─────────────────────────────────────────────────────────┤
│ 阶段 3:软中断 & NAPI 轮询 │
│ ksoftirqd / NET_RX_SOFTIRQ → NAPI poll 批量收包 │
├─────────────────────────────────────────────────────────┤
│ 阶段 4:内核协议栈 │
│ IP 层 → TCP/UDP 层 → 查找 socket → 放入 socket buffer │
├─────────────────────────────────────────────────────────┤
│ 阶段 5:Socket Buffer │
│ sk_receive_queue 排队等待用户态读取 │
├─────────────────────────────────────────────────────────┤
│ 阶段 6:用户态唤醒 & 数据拷贝 │
│ epoll_wait 返回 → recv/read → 数据从内核拷贝到用户态 │
├─────────────────────────────────────────────────────────┤
│ 阶段 7:应用层处理 │
│ JSON 解析 → 策略计算 → 下单 / 信号转发 │
└─────────────────────────────────────────────────────────┘
下面逐层展开。
二、阶段 1:NIC 硬件层 #
2.1 数据包到达 #
当交易所推送一条行情更新时,数据以电信号(铜缆)或光信号(光纤)的形式到达服务器的网卡。网卡的 PHY(物理层芯片)将信号还原为数字比特流,MAC(媒体访问控制层)进行帧校验(CRC),确认帧完整后准备接收。
2.2 NIC On-Chip Buffer(网卡片上缓存) #
MAC 校验通过后,数据包并不是直接 DMA 到主存,而是先暂存在网卡芯片内部的 SRAM(On-Chip Buffer)中。这块 SRAM 容量有限(通常几百 KB ~ 几 MB),且所有 Queue 共享,是包从网线到主存之间的"临时停车场"。
包在这里等待 RSS 引擎完成哈希分流、DMA 引擎空闲、PCIe 总线可用后,才被搬运到主存中对应 Queue 的 Ring Buffer。如果 On-Chip SRAM 满了,新到达的包会被静默丢弃(rx_fifo_errors 计数增加),这是比 Ring Buffer 溢出更底层的丢包点。
On-Chip SRAM 是同一张网卡所有 Queue 的共享瓶颈。这也是 HFT 场景下不同业务需要分网卡做物理隔离的硬件层面根因——详见《从一次排查看网卡中断与核心隔离的本质》3.4 节。
2.3 Rx Ring Buffer(环形缓冲区) #
网卡驱动在初始化时,会在主内存中预先分配一组 描述符(Descriptor) 构成环形队列,称为 Rx Ring Buffer。每个描述符包含一个指向预分配内存区域(skb 或 page)的 DMA 地址。
Rx Ring Buffer(内存中,驱动初始化时创建)
┌─────────┬─────────┬─────────┬─────────┬─────────┐
│ desc[0] │ desc[1] │ desc[2] │ desc[3] │ ... │
│ addr=A │ addr=B │ addr=C │ addr=D │ │
└────┬────┴────┬────┴────┬────┴─────────┴─────────┘
│ │ │
▼ ▼ ▼
┌──────┐ ┌──────┐ ┌──────┐
│ 数据 │ │ 数据 │ │(空闲)│ ← 预分配的内存页
│ 区 A │ │ 区 B │ │ 区 C │
└──────┘ └──────┘ └──────┘
关键概念:
- Head 指针:NIC 硬件维护,指向下一个要写入的描述符位置。
- Tail 指针:驱动维护,指向最后一个已处理(已被内核取走)的描述符。
- 当 Head 追上 Tail 时,Ring Buffer 满了,新到达的包会被 NIC 直接丢弃(rx_missed_errors 或 rx_dropped 计数增加),软件层面完全无感知。
2.4 DMA(直接内存访问) #
NIC 不通过 CPU 搬运数据。它通过 DMA 引擎 直接将数据包写入 Ring Buffer 描述符指向的主内存地址。整个过程 CPU 不参与,但会占用 PCIe 总线带宽 和 内存带宽。
NIC ──DMA──→ PCIe 总线 ──→ 内存控制器 ──→ 主内存 (Ring Buffer)
│
└── 带宽瓶颈点:PCIe Gen3 x1 ≈ 1GB/s,Gen4 x1 ≈ 2GB/s
AWS ENA 通常为 PCIe Gen3/Gen4 多 lane
DMA 写入完成后,NIC 更新描述符的状态标志(标记为"已完成"),然后触发中断通知 CPU。
2.5 多队列(Multi-Queue) #
现代网卡支持多个独立的 Rx Queue,每个队列有自己的 Ring Buffer 和独立的中断号(IRQ)。
NIC 硬件内部
┌──────────────────────────────────────────────────┐
│ RSS Hash 引擎 │
│ │
│ TCP/UDP: 提取四元组 {src_ip, dst_ip, │
│ src_port, dst_port} │
│ 其他IP: 提取二元组 {src_ip, dst_ip} │
│ (协议号用于选择哈希模式,不参与哈希计算) │
│ │
│ Toeplitz 哈希 → 32-bit hash │
│ RETA[hash & mask] → 目标队列号 │
└──────────────────────┬──────────────────────────┘
│
┌──────────────────┼──────────────┬──────────┐
▼ ▼ ▼ ▼
Queue 0 Queue 1 Queue 2 ... Queue 31
Ring Buf Ring Buf Ring Buf Ring Buf
IRQ 132 IRQ 133 IRQ 134 IRQ 163
│ │ │ │
▼ ▼ ▼ ▼
CPU X CPU Y CPU Z CPU W
同一条 TCP 连接(四元组固定)的所有包,hash 值相同,始终落入同一个队列。这保证了单连接内的包顺序,同时让不同连接的处理并行化。
实际数据佐证 #
在你的系统中,enP11p4s0 有 32 个 Rx/Tx 队列(Queue 0 ~ Queue 31),每个队列有独立的 IRQ 编号(IRQ 132 ~ IRQ 163),分布在不同的 CPU core 上。这就是 RSS(Receive Side Scaling)在发挥作用。
延伸阅读:关于多队列网卡架构、RSS 与 CPU 绑核的系统优化实践,可参考《高性能网络I/O优化原理与实战》。
三、阶段 2:硬中断(Hard IRQ) #
3.1 什么是中断 #
CPU 正在执行用户代码(比如你的策略计算),NIC 发出一个电信号(通过 MSI-X 中断机制),CPU 立即暂停当前工作,保存上下文(寄存器状态等),跳转到该 IRQ 对应的中断处理函数。
时间线:
─────────────────────────────────────────────────────
策略计算 ← CPU 正在执行
│
├── NIC 发出 MSI-X 中断
│
├── CPU 保存当前上下文
├── 跳转到 NIC 驱动的中断处理函数 ← 硬中断上下文
├── 关闭该队列的中断
├── 调度 NAPI(注册 softirq)
├── 退出硬中断
│
策略计算 ← CPU 恢复执行(但 softirq 即将打断)
3.2 硬中断做什么 #
硬中断处理必须极快(微秒级),因为在硬中断上下文中:
- 所有本地中断被屏蔽,其他设备的中断也无法被响应。
- 不能睡眠、不能执行耗时操作。
所以 Linux 网络子系统的设计哲学是:硬中断做最少的事,把真正的工作推迟到软中断。
硬中断处理函数(以 ENA 为例)只做两件事:
- 保持该队列的中断关闭。注意这一步不是
napi_schedule做的——它只负责调度,不碰设备寄存器。以 ENA 为例,设备在触发中断时会自动 mask 该向量,驱动直到本轮 poll 结束(ena_unmask_interrupt)才重新打开中断。净效果是:poll 期间该队列不再产生新中断,避免了"中断风暴"。 - 调度 NAPI 轮询(
napi_schedule置位NAPI_STATE_SCHED,将该队列的napi_struct挂到当前 CPU 的 softirq 待处理列表中并 raiseNET_RX_SOFTIRQ)。
// ENA 驱动硬中断处理函数(简化)
static irqreturn_t ena_intr_msix_io(int irq, void *data)
{
struct ena_napi *ena_napi = data;
napi_schedule_irqoff(&ena_napi->napi); // 调度 NAPI,仅此一步
return IRQ_HANDLED;
}
3.3 IRQ 亲和性(smp_affinity) #
每个 IRQ 可以绑定到特定的 CPU core,通过 /proc/irq/<IRQ号>/smp_affinity_list 设置。
# 查看 IRQ 132(enP11p4s0-Tx-Rx-0)的亲和性
cat /proc/irq/132/smp_affinity_list
# 设置为仅在 CPU 5 上处理
echo 5 > /proc/irq/132/smp_affinity_list
irqbalance 守护进程 会动态调整 IRQ 的亲和性以"均衡负载"。但对于延迟敏感的交易系统,这种动态迁移是有害的——IRQ 迁移到新 core 时,CPU cache 是冷的,处理延迟会出现突刺。
实际数据佐证 #
你的 IRQ 数据中,很多队列在 2~3 个 core 上都有大量中断计数(例如 Queue 28 在 CPU98 和 CPU184 上各有约 2 亿次),这正是 irqbalance 动态迁移的典型特征。
四、阶段 3:软中断 & NAPI 轮询 #
4.1 软中断(Softirq) #
当硬中断退出后,内核检查当前 CPU 上是否有待处理的 softirq。网络收包使用的是 NET_RX_SOFTIRQ。
软中断的执行有两种时机:
- 硬中断退出时:
irq_exit()内部会检查 pending softirq 并立即执行。 - ksoftirqd 内核线程:如果 softirq 处理时间过长或累积过多,由专门的内核线程
ksoftirqd/N(N 为 CPU 编号)接管处理。
硬中断退出
│
▼
检查 pending softirq ──→ 有 NET_RX_SOFTIRQ
│
▼
执行 net_rx_action()
│
▼
遍历当前 CPU 的 NAPI poll_list
│
▼
调用每个 napi_struct 的 poll 函数(ENA 驱动实现)
│
▼
从 Ring Buffer 批量取包(budget 限制,默认 64)
4.2 NAPI(New API)轮询机制 #
NAPI 是 Linux 网络子系统的核心优化,解决了高速网络下的中断风暴问题。核心思想:第一个包触发中断,之后切换为轮询模式批量收包,直到队列为空再重新开启中断。
状态机:
┌──────────────────────────────────────────┐
│ │
▼ │
中断模式 ──收到包──→ 触发硬中断 │
│ │ │
│ ▼ │
│ 关闭该队列中断 │
│ │ │
│ ▼ │
│ 进入轮询模式 ◄───┐ │
│ │ │ │
│ ▼ │ │
│ poll() 批量取包 │ │
│ │ │ │
│ 还有包? │ │
│ / \ │ │
│ 是 否 │ │
│ │ │ │ │
│ └───→ 继续 poll │ │
│ │ │
│ 重新开启中断 ──────────┘
│ (napi_complete)
│
└────────── 等待下一个中断 ────────────────
关键参数:
- budget:单次 poll 最多处理的包数量,默认 64。处理完 budget 个包后,即使队列中还有包,也会让出 CPU(通过重新调度 softirq),防止一个队列饿死其他队列或用户进程。
- net.core.netdev_budget:所有 NAPI 设备在单次
net_rx_action中的总 budget,默认 300。
4.3 收包过程详解 #
当 NAPI poll 函数被调用时,驱动执行以下操作:
poll() 函数内部
│
▼
读取 Ring Buffer 描述符 ──→ 检查"已完成"标志
│
▼(已完成的描述符)
取出数据包对应的 skb(socket buffer 结构体)
│
▼
填充 skb 元数据
├── 协议类型(ETH_P_IP)
├── 校验和状态(如果 NIC 硬件已校验,标记 CHECKSUM_UNNECESSARY)
├── VLAN 信息
├── 时间戳(如果 NIC 支持硬件时间戳)
└── 接收队列编号
│
▼
重新分配新的内存页挂到刚处理完的描述符上
│
▼
更新 Ring Buffer 的 Tail 指针(告知 NIC 有新的空闲描述符)
│
▼
调用 napi_gro_receive(skb) 将包送入协议栈
4.4 GRO(Generic Receive Offload) #
在送入协议栈之前,NAPI 会尝试将多个属于同一个 TCP 流的小包合并成一个大包(GRO)。这减少了协议栈需要处理的包数量,降低了 per-packet 的固定开销。
收到 3 个同一 TCP 流的包:
[IP+TCP+payload 100B] [IP+TCP+payload 100B] [IP+TCP+payload 100B]
GRO 合并后:
[IP+TCP+payload 300B] ← 协议栈只需处理 1 次而非 3 次
对交易系统而言,GRO 减少了处理次数但增加了单包延迟。需要澄清一个常见误解:GRO 并不会"等未来的包"——它只合并同一次 NAPI poll 批次内已经到达的包,本轮 poll 结束时(napi_complete_done)会强制 flush 所有攒着的包。所以延迟代价的上界是一个 poll 周期(微秒级),来源是批次内先到的包被扣住陪后到的包一起提交。对低延迟场景这仍然不划算,通常建议关闭 GRO:
ethtool -K <网卡> gro off
五、阶段 4:内核协议栈 #
5.1 IP 层处理 #
GRO 合并后的 skb 被送入 netif_receive_skb(),进入内核网络协议栈。
netif_receive_skb()
│
▼
检查是否有 tcpdump / AF_PACKET 抓包 ──→ 如果有,复制一份给抓包 socket
│
▼
根据 EtherType(0x0800 = IPv4)分发到 IP 层
│
▼
ip_rcv()
├── 校验 IP 头(版本、长度、校验和)
├── Netfilter PREROUTING hook(iptables/nftables 规则)
├── 路由查找:是发给本机的还是需要转发的?
└── Netfilter LOCAL_IN hook
│
▼
根据 IP 头的 protocol 字段分发:
6 = TCP → tcp_v4_rcv()
17 = UDP → udp_rcv()
注意:如果你配置了 iptables 规则或 conntrack(连接跟踪),每个包都会经过 Netfilter hook,这在高频场景下会增加可观的延迟。低延迟系统通常会清空 iptables 规则并卸载 conntrack 模块:
rmmod nf_conntrack
iptables -F
5.2 TCP 层处理 #
tcp_v4_rcv()
│
▼
根据 {src_ip, src_port, dst_ip, dst_port} 查找 socket
│
├── 找不到 → 发送 RST 或丢弃
│
├── 找到 LISTEN socket → 进入三次握手流程
│
└── 找到 ESTABLISHED socket → 正常数据处理
│
▼
TCP 状态机处理
├── 序列号校验
├── 窗口更新
├── ACK 处理
├── 乱序包处理(放入 out_of_order_queue)
└── 数据放入 socket 的接收队列
│
▼
sk->sk_receive_queue(有序数据)
│
▼
唤醒等待在该 socket 上的用户进程
(wake_up_interruptible)
5.3 UDP 层处理 #
UDP 比 TCP 简单得多,没有连接状态、序列号、流控:
udp_rcv()
│
▼
根据 {dst_ip, dst_port} 查找 socket
│
▼
校验 UDP 校验和(如果 NIC 已硬件校验则跳过)
│
▼
将 skb 加入 socket 的接收队列
│
▼
唤醒用户进程
UDP 的关键风险是:socket 接收队列满了之后直接丢包,没有 TCP 的重传机制。你可以通过以下参数增大 UDP buffer:
sysctl -w net.core.rmem_max=26214400
sysctl -w net.core.rmem_default=26214400
延伸阅读:UDP 高丢包问题的完整排查思路和内核 buffer 机制分析,可参考《深度解析UDP高丢包问题》;TCP/WebSocket 场景的缓冲区调优,可参考《高频交易系统中的WebSocket网络缓冲区优化技术》。
六、阶段 5:Socket Buffer #
数据经过协议栈处理后,被放入 socket 的接收队列 sk_receive_queue。这是一个内核空间的 skb 链表。
Socket 结构体
┌────────────────────────────────────┐
│ sk_receive_queue │
│ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │ skb1 │→│ skb2 │→│ skb3 │→ NULL│
│ └──────┘ └──────┘ └──────┘ │
│ │
│ sk_rmem_alloc = 当前队列占用内存 │
│ sk_rcvbuf = 接收缓冲区上限 │
│ │
│ 等待队列 │
│ ┌──────────────────────────┐ │
│ │ task: binance-md (PID 42)│ │
│ │ 状态: TASK_INTERRUPTIBLE │ │
│ └──────────────────────────┘ │
└────────────────────────────────────┘
当 sk_rmem_alloc 达到 sk_rcvbuf 上限时:
- TCP:主机制是流控——停止发送窗口更新(通告 window=0),对端被迫停止发送。但这不等于"本机永远不丢包":内存压力下内核会先尝试 collapse(合并重整队列中的 skb 以节省内存),collapse 无效时会 prune 直接丢弃已入队的数据(
netstat -s中的PruneCalled/OfoPruned/TCPRcvQDrop计数)。端到端数据不丢(对端会重传),但每次重传就是几十毫秒级的延迟尖刺——对行情流来说,这比"丢数据"本身更值得警惕。 - UDP:新到达的包直接丢弃,无任何通知。
七、阶段 6:用户态唤醒与数据拷贝 #
7.1 事件通知机制 #
交易系统通常使用 epoll 管理大量连接。用户进程调用 epoll_wait() 时:
用户进程调用 epoll_wait()
│
▼
没有就绪事件 → 进程进入睡眠(TASK_INTERRUPTIBLE)
│ CPU 可以调度其他任务
│
▼(数据到达,内核协议栈将 skb 放入 socket buffer)
内核调用 sock_def_readable()
│
▼
遍历该 socket 的等待队列
│
▼
找到 epoll 注册的回调函数 ep_poll_callback()
│
▼
将该 socket 对应的 epoll_event 加入 epoll 就绪链表(rdllist)
│
▼
唤醒在 epoll_wait 上睡眠的用户进程
│
▼
epoll_wait 返回,告知用户 "这些 fd 有数据可读"
7.2 数据拷贝 #
用户进程调用 recv() / read() 时:
recv(fd, buf, len, 0)
│
▼
系统调用进入内核态
│
▼
从 sk_receive_queue 取出 skb
│
▼
copy_to_user():将数据从内核 skb 拷贝到用户态 buf
│ │
│ └── 这是一次 CPU 拷贝,对于大量小包
│ 开销不可忽略
▼
释放 skb,更新 sk_rmem_alloc
│
▼
返回用户态,recv() 返回读取字节数
7.3 关键:整条路径涉及哪些 CPU core #
这是最容易被忽视的问题。一个数据包从到达到被应用处理,可能经过 2~3 个不同的 CPU core:
Core A (IRQ 亲和性指定) Core B (进程绑核指定)
┌──────────────────────┐ ┌──────────────────────┐
│ 硬中断处理 │ │ │
│ softirq / NAPI poll │ │ epoll_wait 返回 │
│ 协议栈处理 │ │ recv() │
│ 放入 socket buffer │ =====> │ copy_to_user │
│ 唤醒用户进程 │ IPI │ 应用层处理 │
└──────────────────────┘ └──────────────────────┘
如果中断处理在 Core A,而应用进程绑在 Core B,那么:
- Core A 完成协议栈处理后,调用
wake_up()唤醒 Core B 上的进程。 - 这会触发一个 IPI(Inter-Processor Interrupt,核间中断) 通知 Core B。
- Core B 响应 IPI,将用户进程从睡眠态切换为就绪态。
- 用户进程被调度执行,调用
recv()读数据——这时数据在 Core A 的 L1/L2 cache 中,Core B 读取需要走 L3 或跨 NUMA 节点访问,延迟更高。
最优方案:将 IRQ 和处理该数据的用户进程绑在同一个 core 上,或至少在同一个 NUMA 节点、同一个 L3 cache 域内。
八、完整时序图:一个行情包的生命周期 #
以你的 binance-MD 进程收到一个 WebSocket 行情推送为例:
时间 ──────────────────────────────────────────────────────────────────→
NIC硬件 Core A (IRQ core) Core B (binance-MD)
──────── ───────────────── ───────────────────
t0 包到达PHY
t1 CRC校验通过
t2 暂存On-Chip SRAM
t3 RSS hash→Queue 0
t4 DMA写入Ring Buf
t5 更新描述符状态
t6 发出MSI-X中断 ──→ 硬中断触发
│
t7 中断处理函数执行
napi_schedule()
硬中断返回
│
t8 softirq执行
NAPI poll()
从Ring Buf取skb
│
t9 GRO检查(如未关闭)
│
t10 IP层处理
iptables检查
│
t11 TCP层处理
序列号校验
ACK生成
│
t12 放入socket buffer
wake_up() ──IPI──→ 收到IPI唤醒
│ │
t13 epoll_wait返回
│
t14 recv()系统调用
copy_to_user
│
t15 JSON解析
行情处理
策略计算
典型延迟分布(参考值,实际取决于硬件和配置):
| 阶段 | 延迟范围 | 说明 |
|---|---|---|
| t0-t6: NIC 硬件处理 | 1~5 μs | On-Chip SRAM 暂存 + RSS 分流 + DMA + 描述符更新 |
| t6-t7: 中断投递 | 0.5~2 μs | MSI-X 中断传递到 CPU |
| t7-t8: 硬中断 + softirq 调度 | 0.5~1 μs | 极轻量 |
| t8-t11: NAPI poll + 协议栈 | 2~10 μs | 取决于是否有 iptables、conntrack |
| t12: socket buffer 入队 | 0.1~0.5 μs | |
| t12-t13: IPI + 进程唤醒 | 1~5 μs | 跨 core 唤醒,NUMA 影响大 |
| t13-t14: 系统调用 + 数据拷贝 | 1~3 μs | |
| t15: 应用层处理 | 取决于业务 | JSON 解析通常 5~50 μs |
总计网络栈延迟(不含应用层):约 5~25 μs,最佳情况下可优化到 3~5 μs。
九、你的系统实际状况分析 #
9.1 硬件环境 #
- CPU:AWS Graviton(ARM64),192 个 vCPU
- 中断控制器:GICv3(ARM 通用中断控制器 v3)
- 网卡:6 张 ENA(Elastic Network Adapter),每张 32 个 Rx/Tx 队列
- 中断类型:ITS-MSI(Interrupt Translation Service,ARM 的 MSI-X 等价物)
9.2 流量分布严重失衡 #
从 /proc/interrupts 数据统计:
NIC 总中断数 占比 每队列均值
──────────────────────────────────────────────────────
enP11p4s0 3,533,392,205 98.62% 110,418,506
enP11p8s0 22,629,538 0.63% 707,173
enP11p5s0 15,151,180 0.42% 473,474
enP11p6s0 5,193,195 0.14% 162,287
enP11p9s0 4,121,740 0.11% 128,804
enP11p7s0 2,353,479 0.07% 73,546
enP11p4s0 产生了 98.6% 的中断。需要澄清一个容易混淆的概念:网卡是中断的发起者,CPU 才是中断的执行者。 网卡在 DMA 写入完成后通过 MSI-X 机制向 CPU 发送中断信号,真正执行中断处理函数、运行 softirq 和协议栈的是 IRQ 亲和性所指向的 CPU core。所以"98.6% 的中断负载"更准确的含义是:这张网卡触发了绝大多数中断请求,而这些中断的处理开销落在了它的 32 个队列所绑定的那些 CPU core 上。
这也意味着你的 93 条连接中,绝大多数(尤其是高频的 MD 行情连接)都绑定在这一张网卡上。
9.3 RSS 在主卡上有效,但 IRQ 有漂移 #
以 enP11p4s0 的几个热门队列为例:
Queue 28: CPU 98 = 220,284,507 CPU 184 = 212,413,594
Queue 19: CPU133 = 170,229,711 CPU 168 = 92,717,915
Queue 15: CPU184 = 135,063,025 CPU 185 = 118,131,376
每个队列在 2 个 core 上有大量计数,而非集中在 1 个 core。这是 irqbalance 动态迁移 的特征。
需要特别说明的是:一个 IRQ 在任意时刻只会被投递到一个 CPU 上处理,这个约束从未被打破。/proc/interrupts 显示的是自系统启动以来的累计计数,而非当前状态。以 Queue 28 为例,它并非同时在 CPU98 和 CPU184 上处理中断,而是 irqbalance 在运行过程中将该 IRQ 的亲和性从一个 core 迁移到了另一个 core——迁移前在 CPU98 上累计处理了约 2 亿次,迁移后在 CPU184 上又累计处理了约 2 亿次,两个数字相加才是这个队列的总中断数。
中断从一个 core 迁移到另一个 core 时,新 core 的 cache 是冷的,会导致:
- 处理前几百个包时的延迟突刺(cache miss)。
- 原 core 的 cache 中残留的数据作废。
- 如果用户进程绑在与原 core 同 L3 域的 core 上,迁移后 core 间数据传递路径变长。
9.4 进程绑核 ≠ 中断绑核 #
你提到 binance-MD、okx-MD 各自绑了不同的 core。但这只决定了阶段 6~7(recv() 之后的处理在哪个 core)。阶段 2~4(硬中断、softirq、协议栈处理)运行在 IRQ 亲和性指定的 core 上,与进程绑核无关。
当前可能的情况:
enP11p4s0 Queue 5 的 IRQ → CPU 156 binance-MD 进程 → CPU 20
enP11p4s0 Queue 8 的 IRQ → CPU 117 okx-MD 进程 → CPU 25
CPU 156 CPU 20
┌───────────────────┐ ┌───────────────────┐
│ NIC中断处理 │ │ binance-MD │
│ softirq │ │ │
│ 协议栈 │ IPI │ epoll_wait 返回 │
│ socket buffer入队 │ ───────→ │ recv() │
│ │ │ 业务处理 │
└───────────────────┘ └───────────────────┘
│
└── 数据在 CPU 156 的 cache 中
CPU 20 读取需要跨 cache 域
十、优化方向 #
10.1 停用 irqbalance,手动绑定 IRQ #
# 停止 irqbalance
systemctl stop irqbalance
systemctl disable irqbalance
# 手动将 enP11p4s0 的 Queue 0 绑定到 CPU 105
echo 105 > /proc/irq/132/smp_affinity_list
# 依次为每个队列设置固定的 core
原则:一个 queue 的 IRQ 只绑定一个固定的 core,不要让它漂移。
10.2 IRQ core 与应用进程 core 亲和 #
最理想的做法是将 IRQ 处理和应用进程放在同一个 core 或同一个 L3 cache 域内。
优化前(跨 cache 域):
IRQ → CPU 156 (NUMA node 1)
进程 → CPU 20 (NUMA node 0)
数据路径:L1→L2→L3→跨NUMA互联→L3→L2→L1 延迟 ~100ns
优化后(同 cache 域):
IRQ → CPU 20 (NUMA node 0)
进程 → CPU 21 (NUMA node 0,同 L3)
数据路径:L1→L2→L3(shared)→L2→L1 延迟 ~30ns
更激进的做法是 IRQ 和进程绑同一个 core:中断打断用户进程执行协议栈处理,数据直接在同一 core 的 L1 cache 中,recv() 返回时数据已经在寄存器附近。代价是用户进程的 CPU 时间被中断侵占,需要仔细评估中断负载。
延伸阅读:关于 NUMA 拓扑、L3 cache 域划分与跨节点访问延迟的详细分析,可参考《Intel Xeon 6982P 服务器硬件深度分析与 NUMA 调优指南》。关于 CPU 核心的完整规划方案(isolcpus、Housekeeping、中断核与计算核分离),可参考《低延迟系统的 CPU 核心规划》。
10.3 利用空闲网卡分流 #
你有 6 张 ENA 卡,其中 5 张基本空闲。可以通过操作系统层面的策略路由或应用层面的 SO_BINDTODEVICE 将不同类型的流量分配到不同网卡:
enP11p4s0 → 仅 Binance MD(高频行情,中断量最大)
enP11p8s0 → 仅 OKX MD
enP11p5s0 → Gate TD + HL TD(成交回报,延迟最敏感)
enP11p6s0 → UDP 信号转发(发送为主)
enP11p7s0 → 管理流量(SSH、监控等)
enP11p9s0 → 备用
这样 MD 行情 burst 时,TD 的成交回报走完全独立的硬件路径,从 NIC rx ring 到 PCIe DMA 到中断处理全程不受 MD 流量影响。
延伸阅读:关于网卡物理隔离的深入分析(包括 PPS 瓶颈、Buffer Bloat、收包核与计算核分离的取舍),可参考《从一次排查看网卡中断与核心隔离的本质》。
分散网卡会增加总中断数吗? #
一个自然的疑问是:同样的包量从 1 张网卡分散到 6 张网卡,对 OS 来说总中断数是否一样?答案是:不一定相同,分散后总中断数反而可能增加。
这与 NAPI 的轮询机制有关。回顾 4.2 节,NAPI 的核心逻辑是"第一个包触发中断,之后切换为轮询模式批量收包"。当所有流量集中在 1 张网卡时,每个队列上的包到达非常密集,NAPI 进入轮询后一次 poll 能捞出大量包,轮询结束前又有新包到达,中断被长时间抑制——可能几十个甚至上百个包才产生一次中断。
将流量分散到 6 张网卡后,每个队列的到达密度降为原来的几分之一,包与包之间的间隔变大。NAPI 轮询时更容易遇到"队列空了"的情况,于是更频繁地退出轮询、重新开启中断,下一个包到达又触发一次新中断。同样的总包量,分散后 NAPI 的批量收包效率下降,总中断数可能反而更多。
但对交易系统而言,这个代价是值得的。我们关心的不是"总共产生了多少次中断",而是"成交回报会不会因为行情 burst 而被延迟"。分散网卡带来的硬件级流量隔离——独立的 Ring Buffer、独立的 PCIe DMA 带宽、独立的 IRQ 和 CPU core——远比节省几次中断更有价值。
注意:在 AWS VPC 环境下,多 ENI(网卡)的路由配置需要配合策略路由(
ip rule+ip route),确保回包走正确的网卡。
10.4 关闭不必要的内核特性 #
# 关闭 GRO(减少攒包延迟)
ethtool -K enP11p4s0 gro off
# 清空 iptables(需先于卸载 conntrack,否则 rmmod 会因模块被引用而失败)
iptables -F
# 关闭 conntrack(减少每包处理开销)
# nf_conntrack 常被 nf_nat、xt_conntrack 等模块依赖,需按依赖顺序先卸载它们
rmmod nf_nat xt_conntrack nf_conntrack_netlink 2>/dev/null
rmmod nf_conntrack
# 关闭 Adaptive Interrupt Coalescing
# (ENA 支持 adaptive-rx 与 rx-usecs,不支持 rx-frames,带上会报错)
ethtool -C enP11p4s0 adaptive-rx off rx-usecs 0
10.5 Busy Polling(用户态轮询) #
绕过中断-唤醒机制,让应用线程直接在 recv() / epoll_wait() 时主动轮询网卡队列:
# 全局启用
sysctl -w net.core.busy_poll=50 # epoll_wait 轮询 50μs
sysctl -w net.core.busy_read=50 # recv 轮询 50μs
# 或每个 socket 单独设置
setsockopt(fd, SOL_SOCKET, SO_BUSY_POLL, &val, sizeof(val));
好处是消除了中断→唤醒→调度的延迟,代价是 CPU 空转消耗算力。对于行情处理线程,这通常是值得的。
10.6 硬件时间戳:精确拆分每段延迟 #
排查延迟问题时,时间戳的精度决定了你能看到多细的粒度。本文介绍的七个阶段,每一段的延迟贡献从几十纳秒到几十微秒不等——用毫秒级的应用层时间戳根本看不清。
Linux 提供三个层级的时间戳,对应收包路径上不同的打戳位置:
| 层级 | 打戳位置 | 精度 | 说明 |
|---|---|---|---|
| 网卡硬件时间戳 | 网卡 MAC 层收到帧的瞬间 | 纳秒级 | 不受软件处理延迟影响,零 CPU 开销 |
| 内核软件时间戳 | softirq 处理包时(netif_receive_skb) | 微秒级 | 包含了硬中断延迟和 softirq 排队延迟 |
| 应用层时间戳 | 用户进程 recv() 返回后调用 clock_gettime() | 微秒级 | 包含了全部七个阶段的延迟 |
通过对比不同层级的时间戳,可以精确拆分延迟来源:
硬件时间戳 ──┐
├→ 差值 = 阶段 2-5 延迟(硬中断 + softirq + 协议栈 + socket 排队)
软件时间戳 ──┘
├→ 差值 = 阶段 6 延迟(进程唤醒 + 数据拷贝)
应用层时间戳 ─┘
开启硬件时间戳 #
注意:ENA 的每包 RX 硬件时间戳(
SOF_TIMESTAMPING_RX_HARDWARE)在多数实例类型上并不支持——驱动里有hw_rx_supported的基础设施,但实际可用性取决于实例类型,使用前务必用ethtool -T确认。ENA 普遍提供的是 PHC 硬件时钟(纳秒级时钟源,配合 Amazon Time Sync Service),这与"每包硬件打戳"是两回事。自建机房常用的 Solarflare/Mellanox 网卡则普遍支持每包硬件时间戳。
// 需要网卡硬件支持(先用 ethtool -T 确认)
int flags = SOF_TIMESTAMPING_RX_HARDWARE | SOF_TIMESTAMPING_RAW_HARDWARE;
setsockopt(fd, SOL_SOCKET, SO_TIMESTAMPING, &flags, sizeof(flags));
// 通过 recvmsg() 的控制消息读取时间戳
struct msghdr msg = { .msg_iov = &iov, .msg_iovlen = 1,
.msg_control = ctrl_buf, .msg_controllen = sizeof(ctrl_buf) };
recvmsg(fd, &msg, 0);
// 遍历 cmsg 找到 SCM_TIMESTAMPING
for (struct cmsghdr *cmsg = CMSG_FIRSTHDR(&msg); cmsg; cmsg = CMSG_NXTHDR(&msg, cmsg)) {
if (cmsg->cmsg_level == SOL_SOCKET && cmsg->cmsg_type == SCM_TIMESTAMPING) {
struct timespec *ts = (struct timespec *)CMSG_DATA(cmsg);
// ts[0] = 软件时间戳, ts[2] = 硬件时间戳
}
}
# 检查网卡是否支持硬件时间戳
ethtool -T enP11p4s0
对 HFT 系统来说,硬件时间戳不仅是调试工具,也是生产环境的标配——它是唯一能精确测量"包从网线到应用层到底花了多久"的手段。
延伸阅读:关于网卡隔离、Buffer Bloat 与收包核分离对延迟的影响分析,参见《从一次排查看网卡中断与核心隔离的本质》。
10.7 终极方案:内核旁路(Kernel Bypass) #
完全跳过 Linux 内核协议栈:
传统路径:
NIC → DMA → Ring Buffer → 硬中断 → softirq → IP → TCP → socket → recv()
约 5~25 μs
内核旁路路径:
NIC → DMA → 用户态 Ring Buffer → 应用直接读取
约 1~3 μs
在 AWS 环境下,ENA 驱动支持的旁路方案有限。如果使用自建服务器 + Solarflare/Mellanox 网卡,可以使用:
- OpenOnload / ef_vi(Solarflare):用户态 TCP/UDP 协议栈。
- DPDK:用户态驱动框架,完全接管网卡。
- io_uring + zero-copy recv:Linux 5.19+ 的零拷贝接收,折中方案。
延伸阅读:DPDK 的性能验证与实战部署,可参考《DPDK性能验证技术分享》和《DPDK + WebSocket客户端内存管理故障深度定位实录》;io_uring 的原理与使用,可参考《io_uring 与内存优化技术详解》。
十一、诊断工具速查 #
| 目的 | 命令 |
|---|---|
| 查看网卡队列数 | ethtool -l enP11p4s0 |
| 查看 IRQ 分布 | cat /proc/interrupts | grep enP11p4s0 |
| 查看 IRQ 亲和性 | cat /proc/irq/132/smp_affinity_list |
| 查看 Ring Buffer 大小 | ethtool -g enP11p4s0 |
| 查看丢包统计 | ethtool -S enP11p4s0 | grep -i drop |
| 查看 softirq 统计 | cat /proc/softirqs |
| 查看 socket buffer 使用 | ss -tnpm |
| 查看 NUMA 拓扑 | lscpu --extended 或 numactl -H |
| 实时监控网卡中断 | watch -n1 'cat /proc/interrupts | grep enP11p4s0' |
| 查看内核协议栈丢包 | netstat -s | grep -i drop |
| 检查硬件时间戳支持 | ethtool -T enP11p4s0 |
| 追踪单包延迟 | perf trace -e net:* 或 bpftrace |
十二、总结 #
一个数据包从网线到你的策略引擎,经历了物理层解码、DMA 搬运、硬中断通知、NAPI 轮询收包、IP/TCP 协议栈处理、socket buffer 排队、进程唤醒和数据拷贝共七个阶段。每个阶段都有其延迟贡献和优化空间。
对于你的具体环境,最关键的三个发现是:
- 6 张网卡中只有 1 张产生了 98.6% 的中断,硬件资源严重浪费,不同类型的流量(MD/TD/信号)在 NIC 硬件层面互相竞争。
- irqbalance 导致 IRQ 在 core 之间漂移,破坏了 cache 局部性,引入不可预测的延迟突刺。
- 进程绑核和 IRQ 绑核是独立的两件事,仅做进程绑核不能解决收包路径上的 core 分配问题。
更新记录 #
| 日期 | 变更 |
|---|---|
| 2026-04-10 | 新增 2.2 节"NIC On-Chip Buffer",补全收包路径中网卡片上 SRAM 暂存这一环节;更新全景概览图和完整时序图(插入 t2 暂存步骤);修正 2.5 节 RSS 哈希图中将协议号作为哈希输入的错误(协议号仅用于选择哈希模式);2.3-2.5 节顺延重编号 |
优先级最高的优化动作是:停掉 irqbalance、手动固定 IRQ 亲和性、将不同业务流量分散到不同网卡。这些是零成本但高收益的改动。