Linux 基础体系 · 第 30/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Linux 中断与 Softirq:IRQ、Bottom Half、ksoftirqd 和延迟

在 Linux 中,硬件事件通常不能直接由普通用户线程处理。网卡收到数据、块设备完成 DMA、定时器到期或 PCIe 设备报告状态时,硬件会通过中断通知处理器。内核随后需要在两件事之间取得平衡:

  1. 尽快响应硬件,避免设备丢失状态或队列溢出;
  2. 不要让中断处理长期占用 CPU,从而阻塞调度器和普通任务。

Linux 为此把处理路径拆成不同上下文。最上层是 IRQ 的快速处理,随后是 Bottom Half,也就是“下半部”机制;Softirq 是其中的一种实现,ksoftirqd 则是在 Softirq 不能及时完成时承担处理工作的内核线程。它们共同决定了网络、存储、定时器等高频事件对系统延迟的影响。


一、先建立几个边界:中断、异常、系统调用和调度

1. IRQ 是什么

IRQ 通常指 Interrupt Request,即中断请求。硬件设备通过中断控制器向 CPU 发送请求,CPU 保存当前执行状态,切换到内核预先注册的中断入口。

“IRQ”在不同语境中有两层含义:

  • 硬件中断请求:设备向 CPU 或中断控制器提出请求;
  • Linux 中的 IRQ 编号或 IRQ 描述对象:内核用来标识和管理某条中断线或某个 MSI/MSI-X 向量。

传统设备可能共享一条物理中断线,例如多个设备都连接到 PCI INTx。现代 PCIe 设备通常使用 MSI 或 MSI-X,把中断表示为写入特定内存地址的消息,并且可以拥有多个向量。多队列网卡通常为每个 RX/TX 队列分配 MSI-X 向量,从而把不同队列分布到不同 CPU。

IRQ 不等于所有进入内核的事件:

  • 系统调用:用户程序主动请求内核服务,例如 read()write()
  • 异常:当前指令执行导致的同步事件,例如缺页异常、除零、非法指令;
  • 硬件中断:与当前执行指令通常没有直接因果关系,例如网卡收到数据。

这三类入口最终都可能影响调度,但触发原因和执行上下文不同。中断处理尤其不能假设自己像普通进程一样拥有完整的进程上下文。

2. 中断上下文和进程上下文

Linux 内核代码大致运行在两种重要上下文中。

进程上下文具有当前进程,并且在允许睡眠的情况下可以阻塞等待。例如系统调用路径、内核工作线程和 workqueue 回调通常运行在进程上下文。

中断上下文由硬件中断处理或 Softirq 处理产生,通常没有可供当前逻辑依赖的用户进程上下文。它不能执行可能睡眠的操作,例如:

  • mutex_lock() 在锁不可用时等待;
  • schedule()
  • 可能阻塞的内存分配;
  • 等待 I/O 完成;
  • 访问可能睡眠的用户态拷贝路径。

在内核代码中,in_interrupt()in_atomic() 等检查可以帮助诊断上下文问题,但它们不是设计接口的替代品。判断一个 API 是否能在中断或 Softirq 中使用,应查看该 API 的语义和注释,例如是否标记为 atomic-safe、是否可能 sleep。

一个典型错误是:

irqreturn_t my_irq_handler(int irq, void *dev_id)
{
        mutex_lock(&my_lock);   /* 锁竞争时可能睡眠 */
        ...
        mutex_unlock(&my_lock);
        return IRQ_HANDLED;
}

这段代码在普通进程上下文中可能合法,但在硬件 IRQ handler 中,如果 mutex_lock() 需要等待,就会触发类似:

BUG: sleeping function called from invalid context

中断处理需要使用不会睡眠的同步方式,例如短临界区中的自旋锁;如果后续工作必须睡眠,则应交给 threaded IRQ 或 workqueue。


二、IRQ 的两阶段处理:Top Half 和 Bottom Half

1. Top Half:先确认事件,再快速退出

硬件 IRQ handler 通常被称为 Top Half。它的目标不是完成所有工作,而是完成必须立即完成的部分:

  1. 读取或确认设备状态;
  2. 判断是否确实由本设备产生中断;
  3. 保存必要的状态;
  4. 清除或屏蔽设备中断源,防止中断风暴;
  5. 安排后续处理;
  6. 尽快返回。

例如一个设备收到数据时,Top Half 可能只读取 DMA 完成队列的指针,确认设备状态,并安排 Softirq 或唤醒 IRQ 线程;真正解析大量数据、构造网络包或完成复杂协议处理通常不应全部放在硬件中断入口中。

如果 Top Half 执行时间为 ThT_h,中断到达频率为 λ\lambda,那么仅从 CPU 占用角度看,平均占用比例近似为:

Uh=λThU_h = \lambda T_h

其中:

  • λ\lambda:每秒中断次数;
  • ThT_h:每次硬中断处理时间;
  • UhU_h:该 CPU 用于 Top Half 的时间比例。

例如每秒 50,000 次中断,每次 Top Half 平均耗时 2 微秒:

Uh=50,000×2×106=0.1U_h = 50{,}000 \times 2 \times 10^{-6} = 0.1

也就是一个 CPU 大约 10% 的时间仅用于硬中断。若设备产生 500,000 次中断且处理时间不变,理论占用就达到 100%,还没有计算 Softirq、调度和普通任务。

这只是平均值。突发到达时,真正影响延迟的是队列长度、最长连续执行时间和中断是否被屏蔽,而不只是平均 CPU 占用。

2. Bottom Half:把延后的工作移出硬中断

Bottom Half 是一个历史上的总称,不是单一 API。它表示“中断后续工作”的机制。现代 Linux 中主要包括:

  • Softirq;
  • tasklet;
  • threaded IRQ;
  • workqueue;
  • 某些子系统自己的轮询或延后执行机制。

这些机制的共同目标是:把不必在硬中断入口立即完成的工作延后处理,但它们的上下文和调度能力并不相同。

机制 执行上下文 能否睡眠 并行特性 典型用途
硬 IRQ handler 中断上下文 受 IRQ/CPU 约束 确认设备、应答中断
Softirq 原子/中断相关上下文 同一 Softirq 可在多个 CPU 并行 网络、定时器、RCU 等
tasklet 建立在 Softirq 上 同一 tasklet 通常避免并发执行 旧驱动中的延后处理
threaded IRQ 内核线程上下文 由线程调度 需要较复杂或可睡眠的设备处理
workqueue 内核线程上下文 由 worker pool 调度 低频、复杂、可阻塞工作

“延后”不等于“一定由另一个线程执行”。Softirq 可能直接在硬中断返回路径上执行,因此它的延迟可能很低,但也会延长中断退出路径。


三、Softirq 的定义和执行路径

1. Softirq 是什么

Softirq 是 Linux 内核的一组静态定义的软中断类型。代码通过设置某个 CPU 上的 Softirq pending 位,表示“该 CPU 有一类延后工作待处理”。

常见 Softirq 类型包括:

  • NET_RX_SOFTIRQ:网络接收;
  • NET_TX_SOFTIRQ:网络发送;
  • TIMER_SOFTIRQ:传统定时器相关处理;
  • HRTIMER_SOFTIRQ:高精度定时器相关处理;
  • BLOCK_SOFTIRQ:块层相关处理,具体使用取决于内核版本和路径;
  • RCU_SOFTIRQ:RCU 回调推进;
  • TASKLET_SOFTIRQ:tasklet 使用的 Softirq 类型。

具体类型和实现属于内核内部接口,可能随版本变化。用户态程序不应依赖 Softirq 编号的固定数值。

Softirq 的一个重要性质是:同一种 Softirq 可以在多个 CPU 上同时运行。因此,Softirq 处理函数不能依赖“同一类型永远串行”的假设,必须正确使用每 CPU 数据、原子操作、锁或其他同步机制。

2. 典型路径:硬件中断设置 pending 位

以网络接收为例,抽象流程如下:

sequenceDiagram
    participant Dev as 设备
    participant CPU as 目标 CPU
    participant IRQ as 硬 IRQ handler
    participant S as Softirq 执行点
    participant K as ksoftirqd/CPU
    participant Net as 网络协议栈
    participant App as 用户进程

    Dev->>CPU: DMA 写入接收环
    Dev->>CPU: MSI-X/IRQ
    CPU->>IRQ: 进入硬件中断处理
    IRQ->>IRQ: 确认状态、屏蔽/应答、安排后续处理
    IRQ->>S: 设置 NET_RX pending
    CPU->>S: 中断返回路径尝试处理 Softirq
    S->>Net: NAPI poll/协议栈处理
    alt 工作量未超出本次处理预算
        Net-->>App: 唤醒 socket 等待者或排队数据
    else 仍有 pending 工作
        S->>K: 唤醒当前 CPU 的 ksoftirqd
        K->>Net: 在线程上下文继续处理 Softirq
        Net-->>App: 唤醒 socket 等待者或排队数据
    end

实际网络路径通常还包含 NAPI。NAPI 的思想是:在低负载时可以通过中断快速响应;当中断过于频繁时,驱动在确认一次中断后暂时关闭或抑制接收中断,改为由网络 Softirq 批量轮询接收队列。这样可以减少“每个包一次硬中断”的开销。

3. Softirq 何时运行

Softirq 可能在以下位置运行:

  1. 硬中断返回路径
    IRQ handler 设置了 Softirq pending,硬中断退出前,内核检查并执行 Softirq。

  2. 进程上下文主动触发的路径
    例如内核在启用 bottom half 或完成某些系统调用路径时检查 pending Softirq。

  3. ksoftirqd/N 内核线程
    如果直接处理 Softirq 不合适,内核唤醒对应 CPU 的 ksoftirqd/N,由它继续处理。

不同架构和内核配置的具体调用链会变化,但“设置 pending—在安全点处理—必要时转交 ksoftirqd”的模型是理解问题的关键。


四、为什么需要 ksoftirqd

1. 直接处理 Softirq 的两种风险

如果每次硬中断返回时都无限处理 Softirq,设备持续产生事件时,CPU 可能长时间停留在中断相关路径中:

硬 IRQ
  -> Softirq
      -> 新的 Softirq 又产生
          -> 继续 Softirq
              -> 普通任务迟迟得不到运行机会

这会造成调度延迟,甚至让用户进程表现为“系统没有死机,但几乎不响应”。

反过来,如果 Softirq 一律立即交给线程,低负载下又会引入不必要的线程唤醒和调度开销。Linux 的常见实现采用折中策略:

  • 先在当前返回路径尽快处理一部分;
  • 达到重启次数或时间限制后停止继续处理;
  • 如果仍有 pending Softirq,唤醒每 CPU 的 ksoftirqd/N

这里的具体限制是实现细节,内核版本、配置和架构可能不同,不能把某个版本的常数当作稳定 ABI。稳定的语义是:内核不会让一次 Softirq 处理无限制地占据当前路径,而会在剩余工作较多时转交线程继续处理。

2. ksoftirqd 是什么

ksoftirqd/N 是与 CPU 关联的内核线程,N 表示 CPU 编号。例如:

ksoftirqd/0
ksoftirqd/1

它不是每一种 Softirq 一个线程,而通常是每个逻辑 CPU 一个线程。它的职责是处理该 CPU 上积压、没有在立即路径中完成的 Softirq。

查看这些线程:

ps -eLo pid,tid,psr,cls,rtprio,pri,stat,comm | grep '\[ksoftirqd/'

可能看到类似:

   42    42   0  TS      -   20 S    [ksoftirqd/0]
   47    47   1  TS      -   20 S    [ksoftirqd/1]

字段含义受 ps 版本影响,但通常可以看到:

  • psr:当前或最近运行的 CPU;
  • cls:调度类;
  • stat:线程状态;
  • comm:线程名。

普通内核配置下,ksoftirqd 通常是普通调度类中的内核线程,而不是实时线程。它会受到 CPU 亲和性、CPU 隔离、调度策略和系统负载影响。不要简单地认为“名字中有 kernel,所以它一定优先于用户线程”。

3. ksoftirqd CPU 占用高意味着什么

ksoftirqd/N 长时间占用 CPU,通常说明该 CPU 上的 Softirq 工作量持续超过立即处理路径能够消化的速度。常见原因包括:

  • 网络包速率过高;
  • 单包处理成本高;
  • 网卡中断和队列集中在一个 CPU;
  • RPS/RFS 把处理流量集中到某些 CPU;
  • 块设备完成事件或定时器事件突发;
  • 某个驱动反复产生中断;
  • CPU 被限频、虚拟化调度或其他任务抢占;
  • Softirq 处理本身存在异常重试或大量回调。

但单次看到 ksoftirqd 有 CPU 占用并不代表故障。低延迟、短时间的占用可能只是正常的突发工作。应同时观察增长率、Softirq 类型、丢包、队列积压和业务延迟。


五、延迟到底是哪一种延迟

“中断延迟”经常被混用。至少应区分以下时间段:

  1. 硬件到 CPU 的入口延迟
    设备发出 IRQ 后,CPU 真正进入入口代码之前等待了多久。

  2. IRQ handler 延迟
    从进入 Top Half 到返回需要多久。

  3. Softirq 调度延迟
    Softirq pending 被设置后,到 Softirq 开始执行之间等待多久。

  4. Softirq 服务延迟
    Softirq 从开始处理到完成特定事件需要多久。

  5. 调度延迟
    数据已进入可交付状态,但目标用户线程何时获得 CPU。

  6. 应用可见延迟
    从硬件事件发生到应用完成 read()、收到事件或处理数据的完整时间。

可以写成一个简化模型:

Lapp=Lirq-entry+Ttop+Lsoftirq+Tsoftirq+Lsched+TappL_{\text{app}} = L_{\text{irq-entry}} + T_{\text{top}} + L_{\text{softirq}} + T_{\text{softirq}} + L_{\text{sched}} + T_{\text{app}}

其中:

  • Lirq-entryL_{\text{irq-entry}}:进入中断入口的等待;
  • TtopT_{\text{top}}:Top Half 执行时间;
  • LsoftirqL_{\text{softirq}}:Softirq 开始前的等待;
  • TsoftirqT_{\text{softirq}}:相关 Softirq 工作时间;
  • LschedL_{\text{sched}}:目标任务等待调度的时间;
  • TappT_{\text{app}}:应用实际处理时间。

这个分解解释了两个看似矛盾的现象:

  • IRQ handler 很短,但应用仍然延迟很高:可能积压在 Softirq 或调度阶段;
  • Softirq 总 CPU 占用不高,但尾延迟很差:可能是偶发的长临界区、CPU 亲和性冲突、锁竞争或调度抖动。

1. 一个队列算例

假设一个 CPU 上有任务到达,每个任务由 Softirq 处理,平均服务时间为 ss,到达率为 λ\lambda

CPU 利用率近似为:

ρ=λs\rho = \lambda s

若:

  • λ=100,000\lambda = 100{,}000 个事件/秒;
  • s=4s = 4 微秒;

则:

ρ=100,000×4×106=0.4\rho = 100{,}000 \times 4 \times 10^{-6} = 0.4

平均上只需要 40% CPU,系统可能运行良好。

如果突发期间到达率变为 300,000 个事件/秒:

ρ=300,000×4×106=1.2\rho = 300{,}000 \times 4 \times 10^{-6} = 1.2

服务能力不足,队列必然增长。即使突发结束后平均速率又降下来,系统也要先清空 backlog,因此应用会看到一段额外延迟。

如果通过把事件分布到两个 CPU,使每个 CPU 获得约一半事件,则每个 CPU 的平均利用率降为约 60%。这就是 IRQ affinity、RSS、RPS 等机制可能改善延迟的原因。但如果两个 CPU 共享同一把锁或频繁访问同一 NUMA 节点上的数据,理论上的并行度会被同步和内存访问成本抵消。


六、网络接收为什么经常与 Softirq 绑定

1. 中断逐包处理的成本

最简单的网络处理方式是每收到一个包就产生一次硬中断:

包 1 -> IRQ -> 处理
包 2 -> IRQ -> 处理
包 3 -> IRQ -> 处理

高包速率下,CPU 会花费大量时间进入和退出中断,上下文切换和缓存扰动也会增加。

NAPI 将路径改为:

第一次包到达 -> IRQ -> 关闭/抑制接收中断 -> 设置 NET_RX
NET_RX Softirq -> 批量 poll 多个包
队列为空 -> 重新允许接收中断

NAPI poll 通常有处理预算。预算的存在是为了限制一次批量处理对其他工作造成的影响。预算耗尽但队列仍非空时,后续处理会继续进行,而不是无限处理到队列为空。

因此,看到网络 Softirq 高并不一定意味着 Top Half 慢;可能是:

  • 每次 IRQ 很短,但 NAPI 每轮处理很多包;
  • 包已经进入内核,但协议栈、iptables、桥接、隧道或 socket 分发很重;
  • CPU 上 Softirq 被限制在预算内反复执行;
  • IRQ 分布不均,某个 CPU 过载而其他 CPU 空闲。

2. 中断合并的取舍

网卡常提供 interrupt coalescing:累积若干包或等待一个很短时间后再发中断。

它减少了中断次数和每包固定开销,但会增加等待时间。设:

  • 中断合并等待时间为 TcT_c
  • 批量处理节省的固定开销为 GG

如果 G>TcG > T_c 带来的业务损失,吞吐量通常受益;如果应用要求极低延迟,TcT_c 可能成为主要延迟来源。适合吞吐的配置不一定适合交易、工业控制或实时音视频。

修改网卡参数时应先查看当前配置:

sudo ethtool -c eth0

可能输出:

Coalesce parameters for eth0:
...
rx-usecs: 0
rx-frames: 64

字段和是否支持取决于驱动。尝试修改前应记录原值:

sudo ethtool -c eth0 > /tmp/eth0-coalesce.before

例如:

sudo ethtool -C eth0 rx-usecs 0 rx-frames 32

这不是通用必然有效的优化:

  • 设备可能不支持该参数;
  • 参数可能在网卡重置或重启后恢复;
  • 更低延迟可能换来更高 IRQ/Softirq CPU 占用;
  • 生产环境必须用业务延迟、丢包和 CPU 数据验证,而不是只看吞吐。

七、IRQ affinity、RSS、RPS 和 NUMA

1. IRQ affinity

IRQ affinity 决定某个 IRQ 可以在哪些 CPU 上处理。查看某个 IRQ:

irq=123
cat /proc/irq/$irq/smp_affinity_list
cat /proc/irq/$irq/effective_affinity_list 2>/dev/null || true

也可以查看所有中断的分布:

cat /proc/interrupts

输出示意:

           CPU0       CPU1
 123:      1200    845000   PCI-MSI  eth0-TxRx-0
 124:      1100    832000   PCI-MSI  eth0-TxRx-1

/proc/interrupts 中的数值是自启动以来的累计计数,不是每秒速率。用以下方式观察增长:

watch -n 1 'grep -E "eth0|mlx|nvme" /proc/interrupts'

如果某一个队列对应的 IRQ 只在 CPU0 增长,而业务流量很大,可能存在分布不均。但手工移动 IRQ 前必须确认:

  • 网卡队列与 CPU 的映射;
  • IRQ 是否由 irqbalance 管理;
  • CPU 是否属于同一 NUMA 节点;
  • 应用线程和内存是否也在同一节点;
  • 是否使用 CPU isolation 或实时任务。

临时修改 affinity 的示例:

echo 2 | sudo tee /proc/irq/123/smp_affinity

这里的 2 是十六进制 CPU mask,表示允许 CPU1 处理。不同系统暴露的 mask 文件可能还包括 smp_affinity_list;写入错误可能导致性能下降,甚至让设备中断集中到不合适的 CPU。生产修改前应保存原值并准备恢复。

2. RSS 与 RPS 不是一回事

  • RSS(Receive Side Scaling):通常由网卡硬件根据流哈希把接收流分配到多个硬件队列,再通过 MSI-X 分配到 CPU。
  • RPS(Receive Packet Steering):包进入内核后,由软件决定把处理工作安排到其他 CPU。

RSS 主要影响硬件队列和 IRQ 分布;RPS 可能减少单个 IRQ CPU 的压力,但会增加跨 CPU 唤醒、缓存失效和数据移动成本。

查看 RPS 配置:

for f in /sys/class/net/eth0/queues/rx-*/rps_cpus; do
    printf '%s: ' "$f"
    cat "$f"
done

RPS 不是“CPU 越多越好”。对于同一连接,如果处理频繁跨 CPU,缓存局部性可能变差;如果目标 CPU 位于另一个 NUMA 节点,内存访问成本也可能增加。


八、Softirq 的执行约束和同步问题

1. Softirq 不能睡眠

Softirq 虽然比硬 IRQ 晚执行,但仍然不等于普通线程。Softirq handler 中通常不能:

mutex_lock(...);
msleep(...);
wait_event(...);
copy_to_user(...);

如果工作需要:

  • 分配可能阻塞的内存;
  • 等待另一个设备;
  • 获取 mutex;
  • 执行较长的协议或文件系统操作;

就应考虑 workqueue、threaded IRQ 或专用内核线程。

2. Softirq 可以并行执行

假设一个 Softirq handler 使用全局链表:

static struct list_head pending;

void my_softirq_action(struct softirq_action *a)
{
        while (!list_empty(&pending)) {
                ...
        }
}

如果该 Softirq 可在多个 CPU 上同时运行,这段代码就存在并发访问风险。应根据上下文选择合适的同步:

  • 只在 Softirq/硬 IRQ 间共享:可考虑 spin_lock_bh()spin_lock_irqsave() 等;
  • 进程上下文也会访问:需要让进程路径禁用 bottom half,或使用正确的锁组合;
  • 适合生产者/消费者模式:可以使用每 CPU 队列,减少全局锁;
  • 数据读多写少:可能使用 RCU,但回收和生命周期必须完整设计。

锁后缀本身表达了上下文关系:

  • spin_lock_irqsave():保存并关闭本地硬中断,适合保护可能被本地 IRQ 访问的数据;
  • spin_lock_bh():禁止本地 Softirq,适合进程上下文与 Softirq 共享数据;
  • 普通 spin_lock():只解决互斥,不自动阻止本地中断或 Softirq 重入。

具体使用方式取决于数据在哪些上下文访问。错误地扩大 IRQ 关闭范围会降低系统响应能力;锁范围过大则会增加 Softirq 和 IRQ 延迟。


九、tasklet、threaded IRQ 和 workqueue 的边界

1. tasklet

tasklet 是建立在 Softirq 之上的历史机制。它提供了比直接注册 Softirq 更方便的接口,并且同一个 tasklet 通常不会在多个 CPU 上同时执行。

但 tasklet 仍然:

  • 不能睡眠;
  • 可能延长 Softirq 处理;
  • 不适合需要严格调度和优先级控制的复杂工作。

在现代内核开发中,新代码通常应优先考虑更明确的机制,尤其是 threaded IRQ 或 workqueue。tasklet 在许多现有驱动中仍能看到,但“代码能编译”不等于它适合新的设计。

2. threaded IRQ

threaded IRQ 把中断处理分为快速 handler 和线程 handler。典型接口是:

request_threaded_irq(irq,
                     my_irq_top,
                     my_irq_thread,
                     flags,
                     "my-device",
                     dev);

概念上:

irqreturn_t my_irq_top(int irq, void *data)
{
        if (!device_asserted_irq(data))
                return IRQ_NONE;

        mask_device_irq(data);
        return IRQ_WAKE_THREAD;
}

static irqreturn_t my_irq_thread(int irq, void *data)
{
        struct my_device *dev = data;

        mutex_lock(&dev->lock);
        drain_device_events(dev);
        mutex_unlock(&dev->lock);

        unmask_device_irq(dev);
        return IRQ_HANDLED;
}

关键路径是:

  1. Top handler 判断 IRQ 是否属于本设备;
  2. 屏蔽或确认设备状态;
  3. 返回 IRQ_WAKE_THREAD
  4. 内核唤醒对应的 IRQ 线程;
  5. 线程 handler 在进程上下文中处理复杂逻辑;
  6. 完成后重新允许设备中断。

IRQ 线程可以睡眠,因此适合需要 mutex 或较复杂设备访问的场景。但它不是免费优化:

  • 线程唤醒和调度会增加路径延迟;
  • 如果 IRQ 线程优先级或 affinity 配置不当,设备事件仍会积压;
  • 不应在 Top handler 中做大量工作后再“形式上”使用线程。

3. workqueue

workqueue 把工作排入内核 worker 线程。示例:

static void my_work_fn(struct work_struct *work)
{
        struct my_device *dev =
                container_of(work, struct my_device, work);

        mutex_lock(&dev->lock);
        process_deferred_events(dev);
        mutex_unlock(&dev->lock);
}

Top Half 或 Softirq 中可以:

schedule_work(&dev->work);

workqueue 适用于可睡眠、非实时、可以由 worker 调度的任务。若工作必须在特定 CPU 执行或需要隔离,可以使用专用 workqueue,但这会增加线程和管理成本。


十、Softirq 的状态和数据流

可以把一次事件抽象为以下状态转换:

stateDiagram-v2
    [*] --> DevicePending: 设备产生事件
    DevicePending --> IRQRunning: CPU 进入 IRQ handler
    IRQRunning --> NoDeferredWork: 仅需应答/确认
    IRQRunning --> SoftirqPending: 设置 Softirq pending
    SoftirqPending --> SoftirqRunning: 返回路径或其他安全点开始处理
    SoftirqRunning --> Completed: 队列在本轮处理完
    SoftirqRunning --> KsoftirqdRunnable: 达到本轮限制仍有工作
    KsoftirqdRunnable --> KsoftirqdRunning: ksoftirqd 获得 CPU
    KsoftirqdRunning --> Completed: backlog 被清空
    KsoftirqdRunning --> KsoftirqdRunnable: 仍有新工作
    NoDeferredWork --> [*]
    Completed --> [*]

需要注意三个容易误解的地方:

  1. SoftirqPending 不是一个用户态可观察的稳定状态对象,而是内核内部 pending 位和队列状态的抽象;
  2. ksoftirqd 被唤醒不代表它立即运行,中间仍可能等待调度;
  3. Softirq 处理过程中可能再次产生 Softirq,因此“开始执行一次”不等于“所有工作已经完成”。

十一、如何观察 IRQ、Softirq 和 ksoftirqd

1. 查看 IRQ 计数

cat /proc/interrupts

重点看:

  • 各 CPU 的计数是否均衡;
  • 某个设备 IRQ 是否异常快速增长;
  • 是否有单个 CPU 承担几乎全部网卡或 NVMe 队列;
  • 是否出现设备中断计数停止增长。

/proc/interrupts 是累计值。判断速率需要连续采样:

for i in 1 2 3 4 5; do
    date +%T
    grep -E 'eth0|nvme|mlx|virtio' /proc/interrupts
    sleep 1
done

2. 查看 Softirq 类型

cat /proc/softirqs

输出示意:

                    CPU0       CPU1
          HI:           0          0
       TIMER:      12345      23456
      NET_TX:        800       1200
      NET_RX:      98765     345678
       BLOCK:        100        240
   IRQ_POLL:          0          0
    TASKLET:        120        300
      SCHED:       4567       6789
      HRTIMER:        3          4
          RCU:      7890      11234

这同样是累计计数。字段名称和 Softirq 类型会因内核版本而变化。若 NET_RX 增长很快,应结合网卡队列、NAPI、协议栈和应用接收情况分析;不能仅凭某个数字判断性能问题。

3. 查看网络 backlog

cat /proc/net/softnet_stat

该文件每行通常对应一个 CPU,每行是十六进制字段。字段布局不是适合普通脚本长期依赖的稳定用户态 ABI,不同内核版本可能增加或调整字段。常见实现中,字段包含处理次数、因 backlog 限制而丢弃的次数、处理时间预算耗尽次数等信息。

安全做法是:

  • 记录同一内核版本的字段解释;
  • 比较一段时间内的增量;
  • ethtool -S eth0、应用丢包和业务延迟对照;
  • 不把某一列在所有版本中硬编码成同一个含义。

4. 查看 ksoftirqd 的实际 CPU

pidstat -t -p ALL 1

或:

top -H

观察:

  • ksoftirqd/N 是否持续占用 CPU;
  • 是否只集中在少数 CPU;
  • 是否与 IRQ 计数和 NET_RX 增长同步;
  • 是否存在高优先级实时任务长期占用同一 CPU。

如果系统没有 pidstat,可以使用:

ps -eLo pid,tid,psr,stat,pcpu,comm | grep '\[ksoftirqd/'

这些工具给出的是线程级 CPU 使用情况,不直接给出每个 Softirq 的处理时延。

5. 使用 ftrace 或 trace-cmd 看入口和退出

如果系统安装了 trace-cmd,可以尝试:

sudo trace-cmd record \
  -e irq:irq_handler_entry \
  -e irq:irq_handler_exit \
  -e irq:softirq_entry \
  -e irq:softirq_exit \
  -e sched:sched_switch \
  sleep 10

sudo trace-cmd report

这些 tracepoint 的可用性应先检查:

sudo trace-cmd list -e | grep -E 'irq|softirq|sched_switch'

如果事件名或工具支持不同,应以本机列出的事件为准。分析时关注:

  • IRQ handler 的持续时间;
  • Softirq 从 entry 到 exit 的持续时间;
  • Softirq 是否频繁转入 ksoftirqd
  • ksoftirqd 是否被其他任务延迟调度;
  • IRQ、Softirq 和目标应用是否位于同一个 CPU。

也可以直接查看 tracefs:

mount | grep trace
ls /sys/kernel/tracing/events/irq/

生产环境使用 tracing 需要控制采样时间和事件数量。过度开启函数级 tracing 或高频事件会改变被测系统,尤其会放大中断和调度开销。


十二、常见故障路径

1. 硬中断风暴

可能的路径是:

设备状态未清除
    -> IRQ handler 返回
    -> 同一 IRQ 立即再次进入
    -> CPU 长时间执行 IRQ handler
    -> 普通任务和 Softirq 得不到运行机会

表现包括:

  • /proc/interrupts 某个 IRQ 极快增长;
  • 某个 CPU 的硬中断占用异常高;
  • 应用延迟和系统响应急剧恶化;
  • 驱动日志可能出现 repeated interrupt、timeout 或 reset。

原因可能是错误的设备应答顺序、共享 IRQ 判断错误、硬件故障或驱动 bug。直接屏蔽 IRQ 可能暂时止住 CPU 消耗,但也会导致设备不再工作,不能作为无条件修复。

2. Softirq backlog 持续增长

典型路径:

事件到达率 > Softirq 服务率
    -> NAPI/协议栈队列增长
    -> 本轮预算耗尽
    -> pending 保持
    -> ksoftirqd 被反复唤醒
    -> 应用读取延迟、丢包或超时

这里要区分两种情况:

  • ksoftirqd 高 CPU 且 backlog 计数增长:处理能力不足或事件突发;
  • ksoftirqd CPU 不高但 backlog 增长:它可能没有获得 CPU,或者工作被其他瓶颈阻塞;
  • Softirq 高但 backlog 不增长:可能只是高吞吐下正常完成工作。

3. 一个 CPU 过载,其他 CPU 空闲

这通常与以下因素有关:

  • 所有 IRQ affinity 指向一个 CPU;
  • 网卡只有一个 RX 队列;
  • RSS 哈希配置不合理;
  • RPS mask 过窄;
  • 应用线程、IRQ 和 Softirq 被错误地绑定;
  • NUMA 跨节点访问导致迁移策略收益不明显。

此时盲目把所有 IRQ 分散到更多 CPU 可能产生反效果。需要先确认硬件队列、流量模式和应用线程的实际亲和性。

4. 高优先级任务反而引起设备 backlog

在实时或高优先级调度环境中,Softirq 仍然需要 CPU。若一个 SCHED_FIFO 线程长时间运行并禁止同一 CPU 上的其他工作,可能出现:

实时线程长期占用 CPU
    -> ksoftirqd 得不到运行机会
    -> 网卡 RX backlog 增长
    -> 丢包或设备环溢出

这说明“提高应用优先级”不是单向优化。实时设计必须为 IRQ、threaded IRQ、Softirq 和内核 housekeeping 工作保留可运行的 CPU,并正确配置 affinity。


十三、降低延迟时应先判断问题发生在哪一层

情况一:Top Half 太长

检查驱动是否在硬 IRQ handler 中:

  • 扫描过大的设备队列;
  • 做复杂协议解析;
  • 获取可能长时间竞争的锁;
  • 执行不必要的日志输出;
  • 没有正确关闭或确认中断源。

修复方向是缩短硬中断路径,把可延后工作交给 NAPI、threaded IRQ、Softirq 或 workqueue。

情况二:Softirq 服务时间太长

检查:

  • 网络包处理路径;
  • 防火墙、桥接、隧道、虚拟网络;
  • RCU 回调和定时器积压;
  • 单次批量处理是否过大;
  • 是否存在全局锁或缓存抖动。

不能简单地把所有 Softirq 变成 workqueue。这样可能允许睡眠,但会增加调度延迟,并改变原有的吞吐和并发语义。

情况三:ksoftirqd 等不到 CPU

检查:

  • CPU 是否被实时线程占用;
  • CPU 是否被隔离但没有保留 housekeeping CPU;
  • cgroup CPU 配额是否限制内核线程运行;
  • 虚拟机 vCPU 是否被宿主机抢占;
  • IRQ 和 ksoftirqd 是否被绑定到繁忙 CPU。

这时调整 NAPI 参数或网卡中断合并未必有效,因为根本问题是服务线程没有获得足够 CPU。

情况四:CPU 利用率不高但尾延迟高

平均 CPU 使用率掩盖不了以下问题:

  • IRQ affinity 导致局部过载;
  • 突发队列;
  • 锁竞争;
  • NUMA 远端内存访问;
  • CPU 频率切换;
  • IRQ/Softirq 与应用线程之间的调度干扰;
  • 少数长尾 handler。

应使用 tracing 或基于时间戳的采样观察分位数,例如 p99、p99.9,而不是只观察平均值。


十四、生产环境中的修改和恢复

IRQ affinity、RPS、网卡 coalescing、NAPI 相关参数和实时调度设置都可能立即影响生产流量。修改前至少应保存:

mkdir -p /tmp/irq-state

cat /proc/interrupts > /tmp/irq-state/interrupts.before
cat /proc/softirqs > /tmp/irq-state/softirqs.before
cat /proc/net/softnet_stat > /tmp/irq-state/softnet_stat.before
sudo ethtool -c eth0 > /tmp/irq-state/eth0-coalesce.before

for f in /proc/irq/*/smp_affinity_list; do
    [ -r "$f" ] && printf '%s ' "$f" && cat "$f"
done > /tmp/irq-state/irq-affinity.before

验证不能只看“CPU 降了”:

  • 业务端到端延迟是否改善;
  • p99 和 p99.9 是否改善;
  • 网卡、协议栈、socket 是否丢包;
  • /proc/net/softnet_stat 中相关计数是否继续增长;
  • IRQ 是否出现新的单 CPU 热点;
  • 普通任务和实时任务是否受到影响;
  • 重启、驱动 reload 或 irqbalance 是否覆盖了手工配置。

irqbalance 可能自动调整 IRQ affinity。手工设置后,如果发现配置很快恢复,应检查服务状态和管理策略,而不是反复写入 sysfs/procfs。


十五、在 PREEMPT_RT 等配置下不要套用普通内核模型

普通主线内核中,硬 IRQ、Softirq 和 ksoftirqd 的关系如前文所述。但启用 PREEMPT_RT 等实时配置后,部分中断和 Softirq 的执行方式会发生变化:

  • 中断可能被线程化;
  • 自旋锁语义可能变化;
  • Softirq 可能更多地在线程上下文中处理;
  • 优先级继承、抢占和锁延迟会影响路径。

因此,不能把普通内核上的“Softirq 一定不可抢占、一定不会睡眠”简单套到所有实时配置的实现细节上。不过驱动仍应遵守对应内核版本和实时补丁的 API 约束,不能因为某个测试环境中路径被线程化,就在通用 IRQ handler 中随意使用可睡眠接口。

诊断实时系统时,应额外查看:

uname -a
cat /sys/kernel/realtime 2>/dev/null || true

/sys/kernel/realtime 并非所有内核都提供;没有该文件不能单独证明系统一定不是实时内核。还应查看发行版内核配置和文档。


十六、几个容易混淆的结论

“Softirq 就是一个线程”

不正确。Softirq 是内核的一类延后执行机制,可能直接在硬中断返回路径或其他安全点执行;当工作积压时,ksoftirqd/N 才会参与处理。

“ksoftirqd CPU 高说明它有 bug”

不一定。高网络吞吐或块设备负载下,Softirq 本来就可能消耗大量 CPU。需要确认是否伴随 backlog、丢包、延迟或异常增长。

“IRQ 数量少,所以中断开销小”

不一定。一次 IRQ 可能触发大量 NAPI、协议栈或块层工作。应同时看 Softirq 和业务处理路径。

“把 IRQ 分散到所有 CPU 就能降低延迟”

不一定。过度分散会增加跨 CPU通信和缓存失效;跨 NUMA 节点还会增加远端内存访问。正确目标是让硬件队列、Softirq、应用线程和数据内存形成合理的局部性。

“把 Softirq 改成 workqueue 一定更好”

不一定。workqueue 可以睡眠,适合复杂工作,但线程调度和排队会增加延迟;网络接收等高频路径通常需要 NAPI 和 Softirq 的批处理特性。

“降低中断合并参数总能降低延迟”

不一定。它可能减少等待,却提高 IRQ 次数和 CPU 占用。在 CPU 已经接近饱和时,理论上更短的合并等待可能导致更严重的 Softirq backlog。


十七、把完整路径串起来

以网卡收到数据为例,完整的因果链可以写成:

网卡 DMA 写入 RX ring
    -> 网卡发送 MSI-X
    -> 中断控制器把向量路由到目标 CPU
    -> Linux IRQ handler 被调用
    -> 驱动确认设备并安排 NAPI
    -> NET_RX Softirq pending
    -> 中断返回路径或 ksoftirqd 执行 Softirq
    -> NAPI poll 批量取包
    -> 内核网络协议栈处理
    -> 路由、防火墙、socket 分发
    -> 唤醒等待中的应用线程
    -> 应用获得 CPU 并读取数据

其中任何一段都可能成为延迟来源:

  • IRQ 路由错误导致目标 CPU 过载;
  • Top Half 太长导致硬中断延迟;
  • NAPI budget 或 Softirq 处理能力不足导致 backlog;
  • ksoftirqd 没有及时获得 CPU;
  • 网络协议栈或锁竞争导致 Softirq 服务时间增加;
  • 应用线程调度或 NUMA 布局导致最终可见延迟增加。

因此,分析中断延迟不能只看 /proc/interrupts,也不能只看 ksoftirqd 的 CPU 百分比。应把 IRQ 计数、Softirq 类型、网络 backlog、CPU affinity、调度事件和业务端到端延迟放在同一时间窗口内观察。

Linux 的 Bottom Half 机制本质上是在“立即响应硬件”和“允许系统继续调度”之间做分层处理:Top Half 保证快速应答,Softirq 承担高频且不需睡眠的延后工作,ksoftirqd 防止这些工作无限占据中断返回路径,而 threaded IRQ 和 workqueue 则为需要调度、等待和更复杂同步的任务提供进程上下文。理解这条边界,才能在面对 IRQ 热点、Softirq backlog、ksoftirqd 高 CPU 和尾延迟问题时,先定位真正的瓶颈,再选择不会破坏其他路径的调整方式。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。