Skip to main content

从网线到策略引擎:Linux 网络收包全路径深度解析

·12 mins

本文以一个真实的加密货币交易系统为背景,系统性讲解 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 为例)只做两件事:

  1. 保持该队列的中断关闭。注意这一步不是 napi_schedule 做的——它只负责调度,不碰设备寄存器。以 ENA 为例,设备在触发中断时会自动 mask 该向量,驱动直到本轮 poll 结束(ena_unmask_interrupt)才重新打开中断。净效果是:poll 期间该队列不再产生新中断,避免了"中断风暴"。
  2. 调度 NAPI 轮询napi_schedule 置位 NAPI_STATE_SCHED,将该队列的 napi_struct 挂到当前 CPU 的 softirq 待处理列表中并 raise NET_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

软中断的执行有两种时机:

  1. 硬中断退出时irq_exit() 内部会检查 pending softirq 并立即执行。
  2. 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,那么:

  1. Core A 完成协议栈处理后,调用 wake_up() 唤醒 Core B 上的进程。
  2. 这会触发一个 IPI(Inter-Processor Interrupt,核间中断) 通知 Core B。
  3. Core B 响应 IPI,将用户进程从睡眠态切换为就绪态。
  4. 用户进程被调度执行,调用 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 μsOn-Chip SRAM 暂存 + RSS 分流 + DMA + 描述符更新
t6-t7: 中断投递0.5~2 μsMSI-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 是冷的,会导致:

  1. 处理前几百个包时的延迟突刺(cache miss)。
  2. 原 core 的 cache 中残留的数据作废。
  3. 如果用户进程绑在与原 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 --extendednumactl -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 排队、进程唤醒和数据拷贝共七个阶段。每个阶段都有其延迟贡献和优化空间。

对于你的具体环境,最关键的三个发现是:

  1. 6 张网卡中只有 1 张产生了 98.6% 的中断,硬件资源严重浪费,不同类型的流量(MD/TD/信号)在 NIC 硬件层面互相竞争。
  2. irqbalance 导致 IRQ 在 core 之间漂移,破坏了 cache 局部性,引入不可预测的延迟突刺。
  3. 进程绑核和 IRQ 绑核是独立的两件事,仅做进程绑核不能解决收包路径上的 core 分配问题。

更新记录 #

日期变更
2026-04-10新增 2.2 节"NIC On-Chip Buffer",补全收包路径中网卡片上 SRAM 暂存这一环节;更新全景概览图和完整时序图(插入 t2 暂存步骤);修正 2.5 节 RSS 哈希图中将协议号作为哈希输入的错误(协议号仅用于选择哈希模式);2.3-2.5 节顺延重编号

优先级最高的优化动作是:停掉 irqbalance、手动固定 IRQ 亲和性、将不同业务流量分散到不同网卡。这些是零成本但高收益的改动。