一台 64 核服务器配了一张支持 32 队列的万兆网卡,但
sar -n DEV 显示只有 4 个 CPU
在处理网络中断,其余 60
个核空闲——这不是网卡的问题,是流量分发策略没配好。
多队列网卡把入站流量拆分到多个硬件队列,每个队列绑定一个 CPU 中断。但光有硬件队列不够:RSS 决定哪些流量进哪个队列、RPS 在没有硬件 RSS 时提供软件替代、RFS 让包被处理它的应用所在的 CPU 接收、XPS 让发送队列和 CPU 对齐、aRFS 把这一切下沉到硬件。
本文从内核源码拆解这五个机制的实现。
一、多队列基础设施
1.1 netdev_queue 与队列数组
每个 net_device 维护 RX 和 TX
两组队列数组:
// include/linux/netdevice.h
struct net_device {
// ...
struct netdev_queue *_tx; // TX 队列数组
unsigned int num_tx_queues; // 总 TX 队列数
unsigned int real_num_tx_queues;// 实际活跃 TX 队列数
struct netdev_rx_queue *_rx; // RX 队列数组
unsigned int num_rx_queues; // 总 RX 队列数
unsigned int real_num_rx_queues;// 实际活跃 RX 队列数
// ...
};TX 队列结构:
// include/linux/netdevice.h
struct netdev_queue {
struct net_device *dev;
struct Qdisc __rcu *qdisc; // 绑定的排队规则
spinlock_t _xmit_lock; // 发送锁
unsigned long trans_start; // 最后发送时间戳
unsigned long state; // 队列状态标志
// BQL (Byte Queue Limits) 相关字段
struct dql dql;
};RX 队列结构:
// include/linux/netdev_rx_queue.h
struct netdev_rx_queue {
struct xdp_rxq_info xdp_rxq;
struct rps_map __rcu *rps_map; // RPS CPU 映射
struct rps_dev_flow_table __rcu *rps_flow_table; // RFS 流表
struct napi_struct *napi; // 绑定的 NAPI 实例
};关键的是 real_num_*_queues——它可以小于
num_*_queues。ethtool -L eth0 combined 16
修改的就是活跃队列数。未使用的队列不分配中断,不消耗
CPU。
1.2 队列与中断的绑定
每个 RX 队列对应一个 MSI-X 中断向量。中断亲和性通过
/proc/irq/<N>/smp_affinity 设置,决定哪个
CPU 处理该队列的 NAPI 轮询。
# 查看队列数
ethtool -l eth0
# 查看中断分布
cat /proc/interrupts | grep eth0
# 设置中断亲和性
echo 2 > /proc/irq/42/smp_affinity # 绑定到 CPU 1irqbalance
守护进程自动平衡中断分布,但在高性能场景中通常手动配置以避免抖动。
二、RSS:硬件接收端扩展
RSS(Receive Side Scaling)是网卡硬件实现的多队列流量分发。网卡对每个入站包计算哈希值,通过查表决定送入哪个 RX 队列。
2.1 Toeplitz 哈希
RSS 标准使用 Toeplitz 哈希算法。输入是包的 N 元组(通常是四元组:源 IP + 目的 IP + 源端口 + 目的端口),密钥是 40 字节(320 位)的哈希键。
算法原理:将输入的每一位与哈希键做异或移位运算,产生 32 位哈希值。相同流量的包总是产生相同的哈希值,因此始终被分到同一个队列——这保证了同一 TCP 连接的包在同一 CPU 上顺序处理。
// include/linux/ethtool.h
struct ethtool_rxfh_param {
u8 hfunc; // 哈希函数(ETH_RSS_HASH_TOP = Toeplitz)
u32 indir_size; // 间接表大小
u32 *indir; // 间接表:hash_value % size → queue_index
u32 key_size; // 哈希键大小(通常 40 字节)
u8 *key; // Toeplitz 哈希键
u32 rss_context; // RSS 上下文 ID
u8 input_xfrm; // 输入变换方法
};2.2 间接表
哈希值不直接映射到队列——中间有一个间接表(indirection table)。表的每个条目存储一个队列编号:
Hash(pkt) = 0x3A7B1C2D
Index = 0x3A7B1C2D % 128 = 45
Queue = indirection_table[45] = 7
→ 包送入 RX Queue 7
间接表的大小通常是 128 或 256。通过修改间接表,可以不改变哈希算法的情况下重新分配流量。比如下线一个 CPU 时,只需要把间接表中指向该队列的条目改指其他队列。
# 查看 RSS 配置
ethtool -x eth0
# 修改间接表和哈希键
ethtool -X eth0 equal 16 # 平均分配到 16 个队列
ethtool -X eth0 weight 1 0 1 0 # 只用奇数队列
# 查看/修改哈希输入字段
ethtool -n eth0 rx-flow-hash tcp4 # 查看 TCP 哈希使用哪些字段
ethtool -N eth0 rx-flow-hash tcp4 sdfn # src/dst IP + src/dst port2.3 多 RSS 上下文
Linux 6.x 支持多个 RSS
上下文(rss_context),允许不同的流量使用不同的间接表。配合
ethtool 的 flow steering 规则,可以把特定流量(比如 VXLAN
内层)定向到专用队列集合。
三、RPS:软件接收包导向
RPS(Receive Packet Steering)是 RSS
的软件实现。当网卡不支持多队列、或队列数少于 CPU 数时,RPS
在 netif_receive_skb() 路径中把包转发到其他 CPU
处理。
3.1 核心数据结构
// include/linux/netdevice.h
struct rps_map {
unsigned int len; // CPU 数量
struct rcu_head rcu;
u16 cpus[]; // 候选 CPU 列表
};
struct rps_dev_flow {
u16 cpu; // 该流量当前绑定的 CPU
u16 filter; // 硬件过滤器 ID(aRFS 用)
unsigned int last_qtail;// 上次排队时的队列尾指针
};
struct rps_dev_flow_table {
unsigned int mask; // 哈希掩码
struct rcu_head rcu;
struct rps_dev_flow flows[];
};每个 RX 队列有自己的 rps_map(候选 CPU
列表)和 rps_dev_flow_table(流表)。
3.2 get_rps_cpu():CPU 选择逻辑
当 NAPI 收包后调用
netif_receive_skb(),如果当前 RX 队列配置了
RPS,内核调用 get_rps_cpu() 选择目标 CPU:
// net/core/dev.c(简化)
static int get_rps_cpu(struct net_device *dev, struct sk_buff *skb,
struct rps_dev_flow **rflowp)
{
struct rps_map *map;
struct rps_dev_flow_table *flow_table;
u32 hash;
int cpu = -1;
map = rcu_dereference(rxqueue->rps_map);
flow_table = rcu_dereference(rxqueue->rps_flow_table);
// 1. 计算包的哈希值(skb_get_hash)
hash = skb_get_hash(skb);
// 2. 如果配置了 RFS,先查 rps_sock_flow_table
if (sock_flow_table) {
u32 ident = sock_flow_table->ents[hash & sock_flow_table->mask];
// 检查应用进程在哪个 CPU 上运行
if ((ident ^ hash) & ~rps_cpu_mask == 0) {
next_cpu = ident & rps_cpu_mask;
// RFS 覆盖 RPS 选择
}
}
// 3. 如果没有 RFS 命中,使用 RPS 映射
if (cpu < 0 && map) {
cpu = map->cpus[reciprocal_scale(hash, map->len)];
}
return cpu;
}如果选出的 CPU 不是当前 CPU,内核将 skb 放入目标 CPU 的
per-CPU backlog
队列(sd->input_pkt_queue),然后触发该 CPU
的 NET_RX_SOFTIRQ。目标 CPU
在自己的软中断中处理这些包。
3.3 IPI 与 backlog
RPS 的核心开销是跨 CPU 投递:
- 将 skb 放入目标 CPU 的
input_pkt_queue - 如果目标 CPU 还没被调度,发送 IPI(Inter-Processor Interrupt)唤醒它
- 目标 CPU 在
process_backlog()中处理包
IPI 的代价约 1-2 微秒。如果流量已经在正确的 CPU 上(比如 RSS 已经做了正确的分发),RPS 的 IPI 反而是额外开销。因此 RPS 主要用于:
- 网卡队列数少于 CPU 数(比如只有 1-2 个队列的虚拟网卡)
- 虚拟化环境中 veth 等不支持 RSS 的设备
3.4 配置
# 为 rx-0 队列启用 RPS,分发到 CPU 0-7
echo ff > /sys/class/net/eth0/queues/rx-0/rps_cpus
# 设置 backlog 队列长度
sysctl -w net.core.netdev_budget=600
sysctl -w net.core.netdev_max_backlog=10000四、RFS:应用感知的流量导向
RPS 把包分到哪个 CPU 是基于哈希——但哈希选出的 CPU 可能不是运行目标应用的 CPU。RFS(Receive Flow Steering)解决这个问题:让包被送到应用进程正在运行的 CPU 上,最大化缓存局部性。
4.1 rps_sock_flow_table
RFS 维护一个全局流表
rps_sock_flow_table,记录每个流量的应用进程当前在哪个
CPU 上:
// include/linux/netdevice.h
struct rps_sock_flow_table {
u32 mask;
u32 ents[] ____cacheline_aligned_in_smp;
};每个条目是 32 位值,高位存储哈希验证(防止冲突),低位存储 CPU 编号。
4.2 记录时机
当应用程序调用
recvmsg()、sendmsg() 等 socket
操作时,内核调用 rps_record_sock_flow()
更新流表:
// include/linux/netdevice.h
static inline void rps_record_sock_flow(struct rps_sock_flow_table *table,
u32 hash)
{
unsigned int index = hash & table->mask;
u32 val = hash & ~rps_cpu_mask;
val |= raw_smp_processor_id(); // 当前 CPU = 应用所在 CPU
if (READ_ONCE(table->ents[index]) != val)
WRITE_ONCE(table->ents[index], val);
}每次应用在 CPU X 上调用
recv(),流表就更新为”这个流的应用在 CPU X
上”。下次该流的包到来时,RPS 会优先把包送到 CPU X。
4.3 与 RPS 的协同
RFS 不是独立工作的——它覆盖 RPS 的 CPU 选择。在
get_rps_cpu() 中:
- 先查
rps_sock_flow_table,看该流的应用在哪个 CPU - 如果查到了,且该 CPU 在
rps_map的候选列表中,就使用它 - 如果没查到(流表中没有该流的记录),回退到 RPS 的哈希选择
RFS 的前提是应用进程有相对稳定的 CPU 亲和性。如果进程频繁在不同 CPU 之间迁移,RFS 的流表会不断更新,反而增加了缓存失效。
4.4 配置
# 设置全局 RFS 流表大小(建议 = 期望并发连接数)
echo 32768 > /proc/sys/net/core/rps_sock_flow_entries
# 设置每个 RX 队列的流表大小
echo 4096 > /sys/class/net/eth0/queues/rx-0/rps_flow_cnt五、XPS:发送端队列选择
XPS(Transmit Packet
Steering)解决发送方向的队列选择问题。没有 XPS
时,netdev_pick_tx() 可能选择一个与当前 CPU
不相关的 TX 队列,导致跨 CPU 竞争发送锁。
5.1 两种映射模式
// include/linux/netdevice.h
enum xps_map_type {
XPS_CPUS = 0, // CPU → TX 队列映射
XPS_RXQS, // RX 队列 → TX 队列映射
XPS_MAPS_MAX,
};
struct xps_map {
unsigned int len;
unsigned int alloc_len;
struct rcu_head rcu;
u16 queues[]; // 候选 TX 队列列表
};
struct xps_dev_maps {
struct rcu_head rcu;
unsigned int nr_ids; // CPU 数或 RX 队列数
s16 num_tc; // 流量类别数
struct xps_map __rcu *attr_map[];
};CPU 模式(XPS_CPUS):每个
CPU 映射到一组 TX 队列。CPU 3 发包时,直接选择与 CPU 3
关联的 TX 队列,不需要竞争其他 CPU 的锁。
RX 队列模式(XPS_RXQS):将
RX 队列映射到 TX
队列。这在请求-响应模式中特别有用——应答包的发送队列与请求包的接收队列对齐,保持
NUMA 局部性。
5.2 netdev_pick_tx():发送队列选择
// net/core/dev.c(简化)
u16 netdev_pick_tx(struct net_device *dev, struct sk_buff *skb,
struct net_device *sb_dev)
{
// 1. 如果驱动实现了 ndo_select_queue,使用驱动的选择
if (ops->ndo_select_queue)
return ops->ndo_select_queue(dev, skb, sb_dev);
// 2. 查 XPS 映射
int new_index = get_xps_queue(dev, sb_dev, skb);
// 3. 如果 XPS 没有命中,使用 skb 哈希
if (new_index < 0)
new_index = skb_tx_hash(dev, sb_dev, skb);
// 4. 缓存选择结果到 skb->queue_mapping
return new_index;
}5.3 配置
# CPU 模式:CPU 0-3 使用 TX 队列 0,CPU 4-7 使用 TX 队列 1
echo f > /sys/class/net/eth0/queues/tx-0/xps_cpus # CPU 0-3
echo f0 > /sys/class/net/eth0/queues/tx-1/xps_cpus # CPU 4-7
# RX 队列模式:RX 队列 N 的回复走 TX 队列 N
echo 1 > /sys/class/net/eth0/queues/tx-0/xps_rxqs
echo 2 > /sys/class/net/eth0/queues/tx-1/xps_rxqs最简单的最佳实践:一对一映射——CPU N → TX Queue N → RX Queue N。这保证了收发路径在同一 CPU 上,最大化 L1/L2 缓存命中率。
六、aRFS:硬件加速的流表
aRFS(Accelerated RFS)把 RFS 的逻辑下沉到网卡硬件。网卡的 Flow Director 功能直接根据五元组把包送到正确的 RX 队列——不需要软件 RPS 的 IPI 开销。
6.1 驱动回调
// include/linux/netdevice.h
struct net_device_ops {
// ...
#ifdef CONFIG_RFS_ACCEL
int (*ndo_rx_flow_steer)(struct net_device *dev,
const struct sk_buff *skb,
u16 rxq_index,
u32 flow_id);
#endif
};当 RFS 检测到一个流应该被分到不同的 RX 队列时,内核调用
ndo_rx_flow_steer(),请求网卡硬件创建或更新
Flow Director 规则:
skb:示例包(包含五元组信息)rxq_index:目标 RX 队列(应用所在 CPU 对应的队列)flow_id:流标识符
网卡硬件收到请求后,将该五元组的规则写入 Flow Director 表。后续该流的包直接被硬件送到目标队列,无需 RPS 的软件重定向。
6.2 流表过期
// include/linux/netdevice.h
bool rps_may_expire_flow(struct net_device *dev, u16 rxq_index,
u32 flow_id, u16 filter_id);当流量停止或应用迁移到其他 CPU
时,rps_may_expire_flow()
检查硬件流表规则是否可以清除。这防止 Flow Director
表被过期规则填满。
6.3 配置前提
aRFS 需要:
- 网卡驱动实现
ndo_rx_flow_steer(Intel ixgbe、i40e、mlx5 等支持) - 系统启用了 RPS 和 RFS
- 网卡固件支持 Flow Director 或 N-tuple 过滤
# 检查是否支持 ntuple(aRFS 前提)
ethtool -k eth0 | grep ntuple
ntuple-filters: on
# 启用 ntuple(如果关闭)
ethtool -K eth0 ntuple on七、完整数据流
把五个机制串联起来,一个包的完整 CPU 分配流程:
┌─ 硬件 ─────────────────────────┐
NIC 收包 │ │
↓ │ RSS: Toeplitz(5-tuple) │
↓ │ → indirection_table[hash%N] │
↓ │ → RX Queue K │
↓ │ │
↓ │ aRFS: Flow Director │
↓ │ → 五元组精确匹配 → RX Queue M │
└────────────────────────────────┘
↓
NAPI 轮询(CPU = IRQ 绑定的 CPU)
↓
netif_receive_skb()
↓
├─ RPS 启用?
│ ↓ 是
│ get_rps_cpu()
│ ├─ RFS 命中?→ 使用应用所在 CPU
│ └─ 否 → 使用 rps_map 哈希选择
│ ↓
│ 目标 CPU != 当前 CPU?
│ ├─ 是 → enqueue_to_backlog() + IPI
│ └─ 否 → 本地处理
↓
协议栈处理 → socket → 应用 recv()
↓
rps_record_sock_flow() ← 更新 RFS 流表
↓
应用 send()
↓
netdev_pick_tx()
├─ XPS 命中?→ 使用 CPU/RXQ 关联的 TX 队列
└─ 否 → skb_tx_hash() 哈希选择
↓
TX Queue → NIC 发送
八、NUMA 感知与性能调优
8.1 NUMA 拓扑对齐
在 NUMA 系统中,网卡通过 PCIe 连接到某个 NUMA 节点。最优配置是:
- 网卡中断绑定到同一 NUMA 节点的 CPU
- RSS 队列映射到同一 NUMA 节点的 CPU
- XPS 同样映射到同一 NUMA 节点
跨 NUMA 访问内存的延迟约 100ns,相比本地访问的 30-40ns 多出 2-3 倍。在 10Gbps 线速处理小包时,这个差异足以成为瓶颈。
# 查看网卡的 NUMA 节点
cat /sys/class/net/eth0/device/numa_node
# 查看 NUMA 拓扑
numactl --hardware
# 将网卡中断绑定到本地 NUMA 节点的 CPU
# 假设网卡在 NUMA 0,CPU 0-15 属于 NUMA 0
for irq in $(grep eth0 /proc/interrupts | awk '{print $1}' | tr -d ':'); do
echo ffff > /proc/irq/$irq/smp_affinity
done8.2 irqbalance 与手动配置
irqbalance 是 Linux
默认的中断负载均衡守护进程。它会周期性地重新分配中断到不同
CPU,对于通用服务器是合理的默认值。
但对于网络密集型应用,irqbalance
的重新分配会破坏缓存局部性。最佳实践是:
- 关闭
irqbalance:systemctl stop irqbalance - 手动设置中断亲和性
- 配置 RSS + XPS 一对一映射
- 如果应用线程数固定,启用 RFS
8.3 常见调优参数
| 参数 | 路径 | 默认值 | 建议 |
|---|---|---|---|
| RX 队列数 | ethtool -L |
取决于网卡 | = CPU 数(同 NUMA 节点) |
| RSS 间接表 | ethtool -X |
均匀分配 | 按需调整 |
| RPS CPU 掩码 | rx-N/rps_cpus |
0(关闭) | 虚拟网卡场景启用 |
| RFS 流表大小 | rps_sock_flow_entries |
0(关闭) | 期望并发连接数 |
| XPS CPU 映射 | tx-N/xps_cpus |
空 | 一对一映射 |
| backlog 队列 | netdev_max_backlog |
1000 | 10Gbps+ 增大到 10000 |
| NAPI budget | netdev_budget |
300 | 高 PPS 增大到 600 |
九、可观测性
9.1 查看当前分发状态
# 查看每个 CPU 的软中断处理量
cat /proc/softirqs | grep NET_RX
# 查看每个 RX 队列的包计数
ethtool -S eth0 | grep rx_queue
# 查看 RPS 统计
cat /proc/net/softnet_stat
# 每行对应一个 CPU:processed, dropped, time_squeeze, ...9.2 bpftrace 追踪
追踪 RPS CPU 选择:
bpftrace -e '
kprobe:get_rps_cpu {
printf("rps_select: dev=%s\n",
((struct net_device *)arg0)->name);
}
kretprobe:get_rps_cpu /retval >= 0/ {
printf(" → cpu=%d\n", retval);
}'追踪 XPS 队列选择:
bpftrace -e '
kretprobe:netdev_pick_tx {
printf("tx_queue=%d cpu=%d\n", retval, cpu);
}'追踪跨 CPU 投递(IPI):
bpftrace -e '
kprobe:enqueue_to_backlog {
printf("backlog: skb→cpu=%d from cpu=%d\n",
arg1, cpu);
}'9.3 softnet_stat 解读
cat /proc/net/softnet_stat每行是一个 CPU,字段含义:
| 列 | 含义 | 警报阈值 |
|---|---|---|
| 1 | 处理的包总数 | 不均匀说明分发不平衡 |
| 2 | backlog 队列满丢包数 | >0 需增大 netdev_max_backlog |
| 3 | time_squeeze 次数 | >0 说明 budget 不够 |
如果第 2 列在某些 CPU 上增长,说明该 CPU 的 backlog 满了——可能是 RPS 把太多流量导到了同一个 CPU。
十、参考文献
- Linux 6.6 内核源码
include/linux/netdevice.h、net/core/dev.c - Linux 6.6 内核源码
include/linux/netdev_rx_queue.h - Linux 内核文档
Documentation/networking/scaling.rst - Tom Herbert, Dave Taht,《Understanding the Linux Networking Stack》
- Intel,《Receive Side Scaling on Intel Network Adapters》
上一篇:隧道协议内核实现:VXLAN、IPIP、GRE 与 WireGuard
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【Kubernetes 网络深度系列】Linux 网络栈全景:一个包从网卡到用户态的完整旅程
从 NIC 驱动到用户态 read(),一个网络包在 Linux 内核中到底经历了什么?本文拆解 sk_buff、NAPI、softirq、netfilter 的完整收包路径,并用 bpftrace 实测追踪每一跳的延迟。
【Linux 网络子系统深度拆解】内核网络调优方法论:从基准测试到生产验证
系统化的 Linux 内核网络调优方法论:从基准测试建立性能基线,到 sysctl 参数与内核数据结构的对应关系,再到中断亲和性、NUMA 拓扑、ring buffer、qdisc 的逐层调优,最终通过 A/B 对比验证生产效果。
【Linux 网络子系统深度拆解】网络子系统内存管理:sk_buff 分配、page pool 与 NUMA
从内核源码拆解网络子系统的内存管理全貌:sk_buff 分配路径与 slab 缓存、page_pool 页面回收机制、NUMA 感知分配策略、socket 内存记账与反压,以及 bpftrace 可观测实战。
【Linux 网络子系统深度拆解】Traffic Control 深度拆解:qdisc、class 与 filter
dev_queue_xmit() 不是直接把包交给网卡——中间还有一层 Traffic Control。本文从 Linux 6.6 内核源码拆解 TC 框架的完整实现:struct Qdisc 与 Qdisc_ops 操作表、pfifo_fast/fq_codel/HTB/TBF 的内核实现差异、TCQ_F_CAN_BYPASS 快路径、TCQ_F_NOLOCK 无锁排队、EDT(Earliest Departure Time)调度模型、TC BPF direct-action 模式,以及 MQ 多队列根 qdisc 与 netdev_queue 的关系。