土法炼钢兴趣小组的算法知识备份

【Linux 网络子系统深度拆解】多队列与流量分发:RSS/RPS/RFS/XPS

文章导航

分类入口
linuxnetworking
标签入口
#rss#rps#rfs#xps#arfs#multiqueue#flow-steering#toeplitz#irq-affinity#numa#bpftrace

目录

一台 64 核服务器配了一张支持 32 队列的万兆网卡,但 sar -n DEV 显示只有 4 个 CPU 在处理网络中断,其余 60 个核空闲——这不是网卡的问题,是流量分发策略没配好。

多队列网卡把入站流量拆分到多个硬件队列,每个队列绑定一个 CPU 中断。但光有硬件队列不够:RSS 决定哪些流量进哪个队列、RPS 在没有硬件 RSS 时提供软件替代、RFS 让包被处理它的应用所在的 CPU 接收、XPS 让发送队列和 CPU 对齐、aRFS 把这一切下沉到硬件。

本文从内核源码拆解这五个机制的实现。

RSS / RPS / RFS / XPS 流量分发全景

一、多队列基础设施

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_*_queuesethtool -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 1

irqbalance 守护进程自动平衡中断分布,但在高性能场景中通常手动配置以避免抖动。

二、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 port

2.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 投递:

  1. 将 skb 放入目标 CPU 的 input_pkt_queue
  2. 如果目标 CPU 还没被调度,发送 IPI(Inter-Processor Interrupt)唤醒它
  3. 目标 CPU 在 process_backlog() 中处理包

IPI 的代价约 1-2 微秒。如果流量已经在正确的 CPU 上(比如 RSS 已经做了正确的分发),RPS 的 IPI 反而是额外开销。因此 RPS 主要用于:

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() 中:

  1. 先查 rps_sock_flow_table,看该流的应用在哪个 CPU
  2. 如果查到了,且该 CPU 在 rps_map 的候选列表中,就使用它
  3. 如果没查到(流表中没有该流的记录),回退到 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 规则:

网卡硬件收到请求后,将该五元组的规则写入 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 需要:

  1. 网卡驱动实现 ndo_rx_flow_steer(Intel ixgbe、i40e、mlx5 等支持)
  2. 系统启用了 RPS 和 RFS
  3. 网卡固件支持 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 访问内存的延迟约 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
done

8.2 irqbalance 与手动配置

irqbalance 是 Linux 默认的中断负载均衡守护进程。它会周期性地重新分配中断到不同 CPU,对于通用服务器是合理的默认值。

但对于网络密集型应用,irqbalance 的重新分配会破坏缓存局部性。最佳实践是:

  1. 关闭 irqbalancesystemctl stop irqbalance
  2. 手动设置中断亲和性
  3. 配置 RSS + XPS 一对一映射
  4. 如果应用线程数固定,启用 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。

十、参考文献


上一篇隧道协议内核实现:VXLAN、IPIP、GRE 与 WireGuard

下一篇分段卸载:GRO/GSO/TSO 的内核实现与陷阱

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-23 · linux / networking

【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 的关系。


By .