﻿# IRQ Affinity —— 中断亲和性：把中断绑到指定 CPU

> "网卡中断落在了跑 Redis 的核上，每次收包都打断 Redis，延迟抖动 10 倍。" —— 这是低延迟系统中 IRQ affinity 最经典的痛。中断亲和性（IRQ affinity）就是**决定哪个（哪些）CPU 核负责处理某个硬件中断**，通过 `/proc/irq/N/smp_affinity` 位图控制。对高吞吐场景，把中断打散到多核可提升并发；对低延迟场景，把中断从关键业务核挪走可消除抖动。


> 本文涉及的 APIC 寄存器和中断投递硬件机制，速查见 [x86-64-registers.md](/concepts/process/x86-64-registers.md)。中断控制器进化史前身（8259A PIC——IRR/ISR/IMR 三寄存器协作+ICW-OCW+INTA 握手+级联）见 [../io/8259.md](/concepts/io/8259.md)。

## 一、IRQ affinity 要解决什么问题？

### 1.1 中断对业务核的"降维打击"

```bash
时间线（没有 IRQ affinity）：
───────────────────────────────────────────────────────────────
CPU 0:  [Redis 处理请求] [Redis 处理请求] [Redis 处理请求]
                         ↑ 网卡中断到达！
                         ↑ CPU 0 被迫停下 Redis，切到内核态处理中断
                         ↑ 中断处理：存现场 → NAPI poll → 收包 → 软中断 → 唤醒
                         ↑ 若干 μs 后，CPU 0 继续跑 Redis
问题：Redis 的请求延迟本来 50μs，被中断打断后变成 80μs（多了 30μs 中断处理）
     如果每秒几千次中断，Redis 几乎每微秒都被打断 → 延迟严重抖动
```

### 1.2 IRQ affinity 的解决思路

```bash
时间线（有 IRQ affinity）：
───────────────────────────────────────────────────────────────
CPU 0:  [Redis 处理请求] [Redis 处理请求] [Redis 处理请求] ...   ← 不被打断！
CPU 1:  [idle]  [网卡中断→收包]  [idle]  [网卡中断→收包]  ...   ← 专门处理中断
```

**核心思想**：把中断处理集中到专用的 CPU 核，让关键业务核不被异步打断。

### 1.3 两种策略的权衡

| 策略 | 做法 | 适用场景 | 效果 |
|------|------|------|------|
| **集中** | 所有中断绑到同一个核 | 低延迟应用（Redis/HFT/实时系统） | 关键业务核不被打断，延迟稳定 |
| **打散** | 不同 IRQ 绑到不同核 | 高吞吐（Nginx/Envoy/网关） | 中断处理并行化，整体吞吐更高 |
| **默认（irqbalance）** | 自动分散 | 通用场景 | 中庸方案 |

## 二、硬件层面：中断是怎么到达特定 CPU 的？

### 2.1 APIC 中断投递系统

x86-64 上，中断投递由两级 APIC 控制：

```plantuml
@startuml
left to right direction
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<io>> #FFCDD2
  BorderColor<<io>> #C62828
  BackgroundColor<<cpu>> #C8E6C9
  BorderColor<<cpu>> #388E3C
}
rectangle "I/O APIC\n(南桥/芯片组)\n─────────────────\n· 24 个 IRQ 输入引脚\n· 重定向表 (RTE)\n  每个条目指定：\n  - 目标 CPU (APIC ID)\n  - 向量号\n  - 投递模式" <<io>> as IOAPIC
rectangle "Local APIC\n(每核一个)\n─────────────────\n· 接收中断\n· 发给本地 CPU\n· 定时器/温度/PMI" <<cpu>> as LAPIC0
rectangle "Local APIC\n(每核一个)" <<cpu>> as LAPIC1
rectangle "CPU 核 0" <<cpu>> as CPU0
rectangle "CPU 核 1" <<cpu>> as CPU1
IOAPIC -right-> LAPIC0 : 中断投递(按目标 APIC ID)
IOAPIC -right-> LAPIC1 : (也可广播/最低优先级)
LAPIC0 -right-> CPU0 : 本地中断
LAPIC1 -right-> CPU1
@enduml
```

### 2.2 IO-APIC 重定向表条目（RTE）

每个 IRQ 在 IO-APIC 中有一个 64 位的重定向表条目（Redirection Table Entry）：

```bash
IO-APIC RTE (64 bits) 简化结构：
┌─────────────────────────────────────────────────────────────┐
│ 63:56 │ 55:32  │ 31:17 │ 16     │ 15:8  │ 7:0              │
│ 目标  │ 保留   │ 保留  │ 屏蔽位 │ 向量  │ 投递模式+状态    │
└─────────────────────────────────────────────────────────────┘
   ↑
   └── Destination Field (8 bits)
       物理模式：APIC ID（4 bits 有效，最多 16 核）
       逻辑模式：逻辑 APIC ID（8 bits 位图，最多 8 核）
投递模式（Delivery Mode）：
  000 = Fixed       → 发给指定目标
  001 = Lowest Priority → 发给目标集合中优先级最低的核
  100 = NMI         → 不可屏蔽中断
  111 = ExtINT      → 兼容 8259A 模式
```

**IRQ affinity 的硬件本质**：修改 IO-APIC 重定向表中的 **Destination Field** 和 **投递模式**，告诉 IO-APIC"这个 IRQ 只能发给哪些 CPU"。

### 2.3 MSI/MSI-X 的中断亲和性

现代 PCIe 设备（网卡/NVMe/GPU）使用 MSI（Message Signaled Interrupts）或 MSI-X，不经过 IO-APIC，直接在 PCIe 事务层写 Local APIC 寄存器：

```bash
MSI 中断投递路径：
设备 → PCIe TLP (写 Local APIC 地址) → Root Complex → Local APIC → CPU
MSI-X 表条目（每个中断向量一条）：
┌──────────────────────────────────────────┐
│ Message Address  │ Message Data │ 控制   │
├──────────────────┼──────────────┼────────┤
│ 目标 APIC ID +   │ 中断向量号   │ 屏蔽位 │
│ 投递模式        │              │        │
└──────────────────────────────────────────┘
修改 MSI-X 的亲和性 → 修改 Message Address 中的目标 APIC ID
内核接口：irq_set_affinity() → 设备驱动 → 写 MSI-X 表
```

## 三、软件接口：`/proc/irq/` 下的亲和性控制

### 3.1 核心文件

```bash
# 每个 IRQ 都有一个目录
ls /proc/irq/
# 1  3  4  7  8  9  10  11  12  13  14  15  ...  126  127  ...
# 每个目录下的亲和性相关文件
ls /proc/irq/126/
# smp_affinity       ← 亲和性位图（要写的内容）
# smp_affinity_list  ← 亲和性 CPU 列表（更友好的格式）
# node               ← 该 IRQ 关联的 NUMA 节点
```

### 3.2 smp_affinity：位图格式

```bash
# 查看 IRQ 126 当前绑定到哪些 CPU
cat /proc/irq/126/smp_affinity
# 00000000,00000000,00000000,00000000,00000000,00000000,00000000,000000ff
# ↑ 这是一个 256 位（8×32 位）的位图，支持最多 256 个 CPU
#   每 8 个十六进制字符对应 32 位
# 位图解读：
#   最低位 (bit 0) = CPU 0
#   bit 1 = CPU 1
#   ...
#   示例中的 ff = 11111111 (bit 0-7 全为 1)
#   表示 IRQ 126 可以被 CPU 0-7 处理
```

**常用操作：**

```bash
# 把 IRQ 126 只绑到 CPU 2
echo 4 > /proc/irq/126/smp_affinity     # 4 = 0b100，bit 2 = 1
# 把 IRQ 126 绑到 CPU 0-3
echo f > /proc/irq/126/smp_affinity     # f = 0b1111，bit 0-3 = 1
# 对于超过 32 核的系统，用完整位图
echo 0,0,0,0,0,0,0,10 > /proc/irq/126/smp_affinity
# 上面的 10（十六进制）= 16（十进制）= bit 4
# 更友好的 smp_affinity_list
cat /proc/irq/126/smp_affinity_list     # 0-3（当前在 CPU 0-3）
echo "2" > /proc/irq/126/smp_affinity_list  # 只绑到 CPU 2
echo "0,2,4" > /proc/irq/126/smp_affinity_list  # 绑到 CPU 0,2,4
```

### 3.3 实际操作：给网卡中断绑核

```bash
# 1. 找到网卡对应的 IRQ
ethtool -i eth0 | grep bus-info     # 找到 PCIe 地址（如 0000:01:00.0）
# 2. 查看该网卡的所有中断向量
ls /proc/irq/ | while read irq; do
    grep -l "eth0" /proc/irq/$irq/* 2>/dev/null && echo "IRQ $irq → eth0"
done
# 或者更直接：网卡的 MSI-X 向量通常在 /sys 下
ls /sys/class/net/eth0/device/msi_irqs/
# 126  127  128  129  130  131  132  133  (8 个 MSI-X 向量)
# 3. 查看当前亲和性
cat /proc/irq/126/smp_affinity_list   # 比如 0-7（默认全核）
# 4. 逐个绑定
echo "2"  > /proc/irq/126/smp_affinity_list   # 队列 0 的中断 → CPU 2
echo "3"  > /proc/irq/127/smp_affinity_list   # 队列 1 的中断 → CPU 3
echo "4"  > /proc/irq/128/smp_affinity_list   # 队列 2 的中断 → CPU 4
echo "5"  > /proc/irq/129/smp_affinity_list   # 队列 3 的中断 → CPU 5
# ... 以此类推
# 5. 对于 NVMe 磁盘中断同理
ls /sys/class/nvme/nvme0/device/msi_irqs/
```

### 3.4 脚本化：批量设置中断亲和性

```bash
#!/bin/bash
# set_irq_affinity.sh — 将 eth0 的中断均匀分散到 CPU 2-9
IRQ_BASE=2  # 从 CPU 2 开始
IFACE=eth0
for irq in $(ls /sys/class/net/$IFACE/device/msi_irqs/); do
    echo "$IRQ_BASE" > /proc/irq/$irq/smp_affinity_list
    echo "IRQ $irq → CPU $IRQ_BASE"
    ((IRQ_BASE++))
done
```

## 四、irqbalance：自动管理的守护进程

### 4.1 irqbalance 的工作原理

`irqbalance` 是一个用户态守护进程，它**自动**根据系统负载决定每个 IRQ 的最佳目标 CPU。

```bash
irqbalance 的决策逻辑：
1. 识别设备类型
   → 网卡、NVMe、GPU、USB 等被分类为不同"设备类"
   → 同一设备类的 IRQ 尽量放在同一"调度域"（NUMA 节点）
2. 分析当前负载
   → 读取 /proc/stat 各 CPU 的 irq/softirq 占比
   → 计算每个 CPU 的"中断负载"
3. 选择目标 CPU
   → 从该设备所属 NUMA 节点的 CPU 中选负载最低的
   → 如果是多队列设备（网卡），将不同队列分到不同 CPU
4. 逐层分组
   ┌─ NUMA 节点 0 ────────────┐  ┌─ NUMA 节点 1 ────────────┐
   │  cache domain (LLC 共享)  │  │  cache domain (LLC 共享)  │
   │  ┌─ 核 0 ─┐ ┌─ 核 1 ─┐  │  │  ┌─ 核 8 ─┐ ┌─ 核 9 ─┐  │
   │  │ IRQ 126│ │ IRQ 127│  │  │  │ IRQ 128│ │ IRQ 129│  │
   │  └────────┘ └────────┘  │  │  └────────┘ └────────┘  │
   └─────────────────────────┘  └─────────────────────────┘
```

### 4.2 什么时候该关掉 irqbalance？

| 场景 | 建议 | 原因 |
|------|:---:|------|
| 低延迟应用（Redis/HFT） | **关掉** | irqbalance 可能把中断挪到业务核 |
| 自己手动绑核 | **关掉** | irqbalance 会覆盖你的设置 |
| 使用 isolcpus 隔离核 | **关掉** | irqbalance 不感知 isolcpus，可能把中断放到隔离核 |
| 通用服务器 | **开着** | 默认策略对大多数场景足够好 |

```bash
# 停止并禁用 irqbalance
systemctl stop irqbalance
systemctl disable irqbalance
# 查看 irqbalance 的状态和它做了哪些决策
irqbalance --debug  # 前台运行，输出决策日志
```

### 4.3 irqbalance 的局限性

```bash
irqbalance 不知道的事：
  ✗ 不知道 CPU 2 上跑着 Redis —— 它只看中断负载，不看业务负载
  ✗ 不知道某个网卡队列的中断和对应的应用线程应该同核（数据局部性）
  ✗ 不知道你的延迟目标（100μs p99）
  ✗ 不知道 isolcpus 的存在（可能把中断放到隔离核上）
因此：低延迟系统几乎总是需要手动设置 IRQ affinity + 关掉 irqbalance
```

## 五、网卡多队列（RSS）与中断亲和性的配合

### 5.1 为什么网卡需要多个 MSI-X 向量？

现代网卡（10G/25G/100G）支持 **RSS（Receive Side Scaling）**——硬件层面将收到的包按哈希（五元组：src IP/dst IP/src port/dst port/protocol）分配到多个接收队列，每个队列有自己独立的 MSI-X 中断向量：

```bash
网卡硬件 RSS：
  收到的包 → 哈希(五元组) → 队列 0 → MSI-X 向量 0 → CPU 2
                          队列 1 → MSI-X 向量 1 → CPU 3
                          队列 2 → MSI-X 向量 2 → CPU 4
                          队列 3 → MSI-X 向量 3 → CPU 5
                          ...
```

### 5.2 最佳实践：队列-中断-CPU 的一一对应

```bash
理想配置（8 队列网卡，CPU 2-9 专门处理网络）：
CPU 2  ← IRQ 126 (队列 0 中断)  ← 网卡队列 0
         用户态线程 A (收队列 0 的包) → 绑到 CPU 2
CPU 3  ← IRQ 127 (队列 1 中断)  ← 网卡队列 1
         用户态线程 B (收队列 1 的包) → 绑到 CPU 3
...
好处：
  1. 中断和业务线程在同一核 → 软中断处理完立即唤醒业务线程，无跨核唤醒开销
  2. 数据在 L1/L2 cache 中 → 中断刚写完数据，业务线程就能从 cache 读到
  3. 无锁 → 每个队列独占一个核，队列操作不需要锁
```

**这就是 DPDK 和 io_uring 高性能网络栈的核心思想**：让中断、软中断、用户态处理都在同一个核上完成，数据不用跨核。

### 5.3 RSS 哈希与中断亲和性的配置

```bash
# 查看网卡队列数和当前 RSS 配置
ethtool -l eth0          # 队列数量
ethtool -x eth0          # RSS 哈希键和间接表
ethtool -S eth0 | grep rx_queue  # 各队列收包统计
# 设置队列数（需要先 down 网卡）
ethtool -L eth0 combined 8   # 8 个收发队列
# 配置 RSS 哈希字段
ethtool -n eth0 rx-flow-hash tcp4 sdfn  # TCP 用 src/dst IP + src/dst port
# 查看各队列中断是否均匀
cat /proc/interrupts | grep eth0
# 126:  12345678  ...  IR-PCI-MSI  eth0-0
# 127:  12400000  ...  IR-PCI-MSI  eth0-1
# 128:  12380000  ...  IR-PCI-MSI  eth0-2
# 各队列中断数应该大致均匀；如果不均匀，检查 RSS 哈希配置
```

## 六、IRQ affinity 在不同设备上的实践

### 6.1 网卡

```bash
# 典型高吞吐配置：网卡中断分散到多个核
# CPU 0 留给系统（调度器/内核线程）
# CPU 1-8 处理网卡中断
# CPU 9-15 跑业务线程
for i in $(seq 0 7); do
    cpu=$((i + 1))
    irq=$(cat /sys/class/net/eth0/device/msi_irqs/ | head -n $((i+1)) | tail -1)
    echo $cpu > /proc/irq/$irq/smp_affinity_list
done
```

### 6.2 NVMe 磁盘

```bash
# NVMe 磁盘也支持多队列，但中断亲和性策略不同
# 磁盘 IO 的瓶颈通常在 IOPS/带宽而非 CPU
# 一般把 NVMe 中断绑到离该 NVMe 设备 PCIe 近的 NUMA 节点的核上
# 查看 NVMe 的 NUMA 节点
cat /sys/class/nvme/nvme0/device/numa_node   # 0 或 1
# 查看 NVMe 的 MSI-X 向量
ls /sys/class/nvme/nvme0/device/msi_irqs/
# 通常 8-16 个向量（比网卡少）
# 绑到同 NUMA 节点的核
numactl -N 0 --cpu-list 2-9  # 确认 CPU 2-9 属于 NUMA 0
for irq in $(ls /sys/class/nvme/nvme0/device/msi_irqs/); do
    echo "2-9" > /proc/irq/$irq/smp_affinity_list
done
```

### 6.3 GPU

```bash
# GPU 中断：通常只需保证不被隔离核（isolcpus）处理即可
# 对 GPU 中断的延迟要求不高（毫秒级可接受）
# 查看 GPU 的中断
grep -i nvidia /proc/interrupts
# 或
grep -i amdgpu /proc/interrupts
```

## 七、中断亲和性与 isolcpus/nohz_full 的配合

在极致低延迟场景中，IRQ affinity 需要与 `isolcpus` 和 `nohz_full` 协同使用：

```bash
# 内核启动参数
isolcpus=2-7 nohz_full=2-7 rcu_nocbs=2-7 irqaffinity=0-1
# isolcpus=2-7     → CPU 2-7 从调度器默认负载均衡摘出，只跑显式绑定的线程
# nohz_full=2-7    → CPU 2-7 只有一个任务时关掉调度时钟中断（tickless）
# rcu_nocbs=2-7    → RCU 回调不在 CPU 2-7 执行
# irqaffinity=0-1  → 所有非显式绑定的中断默认只去 CPU 0-1
```

```bash
效果：
  CPU 0-1  → 处理所有系统中断 + 内核线程 + 杂务
  CPU 2-7  → 只跑你的关键业务线程，几乎不被打断
             （只有显式绑到它们的网卡中断会来，这正是你想要的）
```

## 八、观测与调试

```bash
# 1. 查看所有中断及其在各 CPU 上的计数
cat /proc/interrupts
# 2. 实时监控中断速率
watch -n1 'cat /proc/interrupts | head -20'
# 3. 用 mpstat 看各核的 %irq 和 %soft
mpstat -P ALL 1
# 4. 查看当前所有 IRQ 的亲和性
for irq in $(ls /proc/irq/); do
    [ -f /proc/irq/$irq/smp_affinity_list ] || continue
    name=$(cat /proc/irq/$irq/actions 2>/dev/null | head -1 || echo "unknown")
    cpus=$(cat /proc/irq/$irq/smp_affinity_list)
    echo "IRQ $irq → CPUs [$cpus]  $name"
done | sort -t' ' -k2 -n
# 5. 查看 irqbalance 的决策日志
journalctl -u irqbalance --since "5 minutes ago"
# 6. 用 perf 看中断处理的热点
perf record -e irq:irq_handler_entry -a -g -- sleep 10
perf report
# 7. 统计某个 IRQ 在哪个 CPU 上被处理
cat /proc/irq/126/effective_affinity_list   # 硬件实际投递到的 CPU
cat /proc/irq/126/smp_affinity_list         # 你设置的亲和性
# 注意：effective_affinity 是 smp_affinity 的子集（硬件可能无法满足全部位）
```

## 九、常见问题与排查

| 症状 | 可能原因 | 排查命令 |
|------|------|------|
| 设置了 smp_affinity 但不生效 | irqbalance 在运行，覆盖了设置 | `systemctl status irqbalance` |
| 中断全集中在 CPU 0 | 默认没有打散，irqbalance 也没开 | 手动设置或开启 irqbalance |
| effective_affinity 和 smp_affinity 不一致 | 硬件限制（如 IO-APIC 物理模式只支持前 16 核） | `cat /proc/irq/N/effective_affinity_list` |
| 某些核的 %soft 极高 | 软中断处理集中在该核 | 调整 RPS（Receive Packet Steering）或 RSS 队列数 |
| 延迟抖动（p99 远大于 p50） | 中断落到了业务核上 | 检查业务核的 `/proc/interrupts` 计数 |

## 十、与仓库其他文档的关系

- **中断处理全貌**：[interrupts.md](/concepts/process/interrupts.md) —— 中断分类体系、完整处理流程、上半部/下半部机制、开销量化
- **线程亲和性**：[thread-affinity.md](/concepts/process/thread-affinity.md) —— 线程绑核（CPU affinity），与本文的中断绑核互为补充：线程绑核管"谁在哪个核上跑"，中断绑核管"中断去哪个核"
- **调度器**：[scheduling.md](/concepts/process/scheduling.md) —— 调度域、负载均衡、isolcpus 如何影响中断处理
- **上下文切换**：[context-switch.md](/concepts/process/context-switch.md) —— 中断打断用户线程后触发的切换开销
- **网络内核调优**：[../network/kernel-tuning-net.md](/concepts/network/kernel-tuning-net.md) —— RSS/RPS/RFS 与中断亲和性的配合
- **网卡收包**：[interrupts.md](/concepts/process/interrupts.md) §四 —— NAPI 与软中断的完整收包流程

## 十一、一句话总结

> **IRQ affinity 是把硬件中断绑定到指定 CPU 的能力——通过修改 IO-APIC 重定向表（或 MSI-X 的 Message Address）中的目标 APIC ID 实现，用户态通过 `/proc/irq/N/smp_affinity` 位图控制。对低延迟系统，把中断从关键业务核挪走是消除延迟抖动的第一步（配合 isolcpus + nohz_full）；对高吞吐系统，把网卡多队列中断均匀分散到多核可并行收包。irqbalance 提供自动管理但不懂你的业务语义，低延迟场景必须关掉它并手动设置。**

