网卡每秒收百万个包、磁盘每次 I/O 完成都要通知内核——如果每次都现场完整处理,CPU 会被中断吞没。Linux 的解法是把中断切成两半:上半部只做最紧急的应答,真正的重活推给下半部延迟执行。本文用一个 120 行的 Python 模拟器复现上半部、三种下半部(软中断/tasklet、工作队列、线程化 IRQ)、共享中断线与 NAPI 风暴抑制的完整行为,并用真实运行输出验证每个机制。
一、为什么要切成两半
中断处理函数运行在中断上下文,受到两个硬约束:当前 CPU 的同类中断被屏蔽(甚至全部中断关闭),且绝对不能睡眠——没有进程上下文可切走。如果在中断里做协议解析、内存分配、磁盘 I/O 这类慢活,整个系统的中断响应都会瘫痪。于是一切中断处理都遵循同一模式:上半部(top half)只应答硬件、搬最少的数据,几微秒内返回;下半部(bottom half)在稍后安全的环境完成剩余工作。
二、上半部:最紧急的事
上半部由 request_irq() 注册,触发时按 IRQ 号查到处理链执行。它的职责清单极短:读设备状态寄存器确认中断源、给硬件写应答位(否则设备会持续拉高中断线)、把数据从设备寄存器/DMA 缓冲区搬到内存、标记下半部待执行。耗时通常 1~5 微秒。
三、三种下半部:按场景选
- 软中断 / tasklet:软中断上下文执行,不能睡眠、不能使用可能阻塞的锁,但延迟极低(上半部返回后立即处理)。NET_RX/NET_TX 软中断承载了整个网络协议栈——最高频、最不容延迟的路径用它。tasklet 是建立在软中断上的易用封装,同一 tasklet 保证串行执行。
- 工作队列(workqueue):由 kworker 内核线程在进程上下文执行,可以睡眠、可以取互斥锁、可以做任意耗时操作。代价是要等调度器把 kworker 排上 CPU,延迟在微秒到毫秒级。块设备完成、热插拔事件用它。
- 线程化 IRQ(request_threaded_irq):把上半部本身变成一个可抢占的内核线程,硬中断只剩一个唤醒操作。实时 Linux(PREEMPT_RT)把所有中断默认线程化,换来的好处是中断处理可以被高优先级实时任务抢占,系统延迟上界可控。
四、完整可运行模拟器(Python 3,约 120 行)
下面的程序复现四个场景:网卡收包(tasklet)、磁盘 I/O 完成(工作队列)、共享中断线遍历、NAPI 中断风暴抑制:
#!/usr/bin/env python3
"""中断处理模型模拟:上半部应答、下半部延迟执行、
共享 IRQ 遍历、NAPI 风暴抑制"""
log = []
T = [0.0] # 虚拟时钟(微秒)
def tick(us):
T[0] += us
class SoftIRQQueue:
def __init__(self):
self.pending = []
def raise_softirq(self, fn, tag):
self.pending.append((fn, tag))
log.append(' [t=%.1f] 上半部标记 %s 待执行' % (T[0], tag))
def run_pending(self):
"""上半部返回后、恢复用户态前,内核处理软中断"""
while self.pending:
fn, tag = self.pending.pop(0)
log.append(' [t=%.1f] 下半部执行 %s'
'(软中断上下文,不可睡眠)' % (T[0], tag))
fn()
softirq = SoftIRQQueue()
processed_packets = []
# ---------- 网卡中断:上半部 + tasklet 下半部 ----------
def nic_top_half(pkt):
log.append('[t=%.1f] IRQ 11 触发:网卡收到包 %s' % (T[0], pkt))
log.append(' [t=%.1f] 上半部:读 NIC 状态寄存器、'
'应答中断(关中断,耗时 2us)' % T[0])
tick(2)
def bottom():
processed_packets.append(pkt)
log.append(' [t=%.1f] 协议栈处理 %s 完成,'
'移交 socket 缓冲' % (T[0], pkt))
softirq.raise_softirq(bottom, 'NET_RX_SOFTIRQ(tasklet)')
log.append(' [t=%.1f] 上半部返回,恢复中断' % T[0])
# ---------- 磁盘中断:上半部 + 工作队列(可睡眠) ----------
class WorkQueue:
def __init__(self):
self.q = []
def schedule(self, fn, tag):
self.q.append((fn, tag))
log.append(' [t=%.1f] 上半部投递工作项 %s' % (T[0], tag))
def run(self):
while self.q:
fn, tag = self.q.pop(0)
log.append(' [t=%.1f] kworker 线程执行 %s'
'(进程上下文,可睡眠/可取锁)' % (T[0], tag))
fn()
wq = WorkQueue()
disk_done = []
def disk_top_half(req):
log.append('[t=%.1f] IRQ 14 触发:磁盘完成请求 %s' % (T[0], req))
tick(1.5)
def work():
disk_done.append(req)
log.append(' [t=%.1f] 完成 %s 的页缓存回写收尾'
'(含可能睡眠的 I/O 操作)' % (T[0], req))
wq.schedule(work, 'blk-complete-work')
# ---------- 共享中断线 ----------
def shared_irq_handler(devices, irq=11):
"""共享 IRQ:逐个询问设备是否是自己的中断"""
log.append('[t=%.1f] 共享 IRQ %d 触发,遍历设备链:' % (T[0], irq))
for name, pending in devices:
if pending:
log.append(' %s: 是我的中断 -> 处理' % name)
return name
else:
log.append(' %s: 状态寄存器无挂起 -> 跳过' % name)
return None
# ---------- NAPI 风暴抑制 ----------
def napi_demo(n_packets):
log.append('=== NAPI 中断风暴抑制(%d 个包连续到达) ==='
% n_packets)
log.append('第 1 个包:触发硬中断,上半部关闭 RX 中断、'
'切换轮询模式')
budget = 8
napi_runs = (n_packets + budget - 1) // budget
for i in range(napi_runs):
got = min(budget, n_packets - i * budget)
log.append(' NAPI 轮询第 %d 轮:收包 %d 个'
'(budget=%d),无中断开销' % (i + 1, got, budget))
log.append('队列清空:重新开启 RX 中断。'
'总硬中断次数 = 1(而非 %d)' % n_packets)
print('=== 中断处理模型模拟 ===')
log.append('--- 场景 1: 网卡收包(tasklet 下半部) ---')
nic_top_half('pkt#1')
softirq.run_pending()
log.append('--- 场景 2: 磁盘 I/O 完成(工作队列下半部) ---')
disk_top_half('req#42')
log.append('[t=%.1f] 上半部返回,kworker 线程随后被调度' % T[0])
tick(5)
wq.run()
log.append('--- 场景 3: 共享中断线 ---')
shared_irq_handler([('eth0', False), ('eth1', True),
('sata', False)])
log.append('--- 场景 4: NAPI ---')
napi_demo(20)
for line in log:
print(line)
print()
print('结果汇总:协议栈处理包 %s,磁盘完成 %s'
% (processed_packets, disk_done))
五、真实运行输出与解读
以上代码在 Python 3 下原样运行,输出如下:
=== 中断处理模型模拟 ===
--- 场景 1: 网卡收包(tasklet 下半部) ---
[t=0.0] IRQ 11 触发:网卡收到包 pkt#1
[t=0.0] 上半部:读 NIC 状态寄存器、应答中断(关中断,耗时 2us)
[t=2.0] 上半部标记 NET_RX_SOFTIRQ(tasklet) 待执行
[t=2.0] 上半部返回,恢复中断
[t=2.0] 下半部执行 NET_RX_SOFTIRQ(tasklet)(软中断上下文,不可睡眠)
[t=2.0] 协议栈处理 pkt#1 完成,移交 socket 缓冲
--- 场景 2: 磁盘 I/O 完成(工作队列下半部) ---
[t=2.0] IRQ 14 触发:磁盘完成请求 req#42
[t=3.5] 上半部投递工作项 blk-complete-work
[t=3.5] 上半部返回,kworker 线程随后被调度
[t=8.5] kworker 线程执行 blk-complete-work(进程上下文,可睡眠/可取锁)
[t=8.5] 完成 req#42 的页缓存回写收尾(含可能睡眠的 I/O 操作)
--- 场景 3: 共享中断线 ---
[t=8.5] 共享 IRQ 11 触发,遍历设备链:
eth0: 状态寄存器无挂起 -> 跳过
eth1: 是我的中断 -> 处理
--- 场景 4: NAPI ---
=== NAPI 中断风暴抑制(20 个包连续到达) ===
第 1 个包:触发硬中断,上半部关闭 RX 中断、切换轮询模式
NAPI 轮询第 1 轮:收包 8 个(budget=8),无中断开销
NAPI 轮询第 2 轮:收包 8 个(budget=8),无中断开销
NAPI 轮询第 3 轮:收包 4 个(budget=8),无中断开销
队列清空:重新开启 RX 中断。总硬中断次数 = 1(而非 20)
逐场景解读:
- 场景 1 的延迟最小化:上半部从 t=0 到 t=2us 只做应答,tasklet 在 t=2us 立即执行——中断关闭窗口被压到最短。真实网卡驱动(如 e1000 系列)的 irq_handler 就是读状态、搬 sk_buff、__raise_softirq_irqoff(NET_RX_SOFTIRQ) 三步。
- 场景 2 的延迟权衡:磁盘完成中断在 t=3.5us 就返回了,但真正收尾的页缓存回写要等到 t=8.5us kworker 被调度——多出的 5us 是调度延迟。磁盘 I/O 对这个延迟不敏感,而睡眠能力是刚需(回写要拿页锁、可能要分配内存),所以用工作队列。选哪种下半部,本质是『延迟敏感度 × 是否需要睡眠』的二维决策。
- 场景 3 的共享遍历:现代 PCI 设备普遍共享 IRQ(MSI 之前的 IRQ 11 常常挂一串设备),内核逐个调用注册的 handler,handler 通过读自己设备的状态寄存器判断是否『是我』。这就是为什么共享 IRQ 的 handler 必须足够快——每个无关设备的中断都会顺路调用你一次。
- 场景 4 的风暴抑制:20 个包连续到达时,纯中断模式要 20 次硬中断(每次几微秒的上下文切换开销),NAPI 模式第 1 个包触发中断后立即关闭 RX 中断转入轮询,三轮 budget=8 的批量收包处理完全部 20 个,再开中断。总硬中断次数从 20 降到 1——这是万兆网卡不打满 CPU 的关键技术,DPDK 的纯轮询是这一思路的极端化。
六、理论分析
6.1 中断上下文为什么不能睡眠
睡眠意味着调用 schedule() 切走,而 schedule() 要保存『当前进程』的上下文——中断上下文里 current 指针借用的是被打断进程的 task_struct,自身没有独立的可调度实体。一旦在中断里睡眠,被打断的进程状态会被破坏,且中断返回路径无处恢复,内核直接 panic(『scheduling while atomic』)。这条红线决定了一切下半部机制的存在意义。
6.2 软中断的执行时机
内核在三个时机检查并执行待处理软中断:硬中断返回时(irq_exit)、ksoftirqd 内核线程被唤醒时、local_bh_enable 等显式开启点。为避免软中断饿死用户态(网络风暴下协议栈软中断反复触发),内核限制连续执行次数与时长(MAX_SOFTIRQ_TIME 约 2ms),超出的部分甩给每 CPU 的 ksoftirqd 线程——这是『延迟与公平』的又一重平衡。
6.3 中断亲和性与多核扩展
单队列网卡的所有中断都砸在一个 CPU 上会成为瓶颈;多队列网卡 + MSI-X 给每个队列分配独立中断向量,配合 irqbalance 或手工 /proc/irq/N/smp_affinity 把队列绑到不同核,中断负载随核数线性扩展。RSS(Receive Side Scaling)用五元组哈希把流散到各队列,保证同一连接固定落在一个核上,保住缓存局部性。
七、工程应用
- 观测中断分布:
cat /proc/interrupts列出每个 IRQ 在各 CPU 上的计数;watch -d cat /proc/interrupts能直观看到网卡中断是否挤在单核。cat /proc/softirqs看 NET_RX/NET_TX/TIMER 各类软中断分布。 - 延迟诊断:
cyclictest(rt-tests 套件)测量调度延迟,是实时性验证的标准工具;perf 的 irq 相关 tracepoint(irq:irq_handler_entry 等)可精确定位慢 handler。 - 驱动选择下半部:写驱动时的决策树——需要睡眠吗?需要 → 工作队列或线程化 IRQ;不需要且高频低延迟 → tasklet/软中断。绝大多数新驱动应优先 workqueue 或 threaded IRQ,tasklet 已被内核社区标记为逐步淘汰(因其全局串行化语义难维护)。
- 网络调优:网卡多队列(ethtool -L)、中断合并(ethtool -C 调 rx-usecs)、NAPI budget(sysctl net.core.netdev_budget)是网络密集型服务的三板斧;CPU 隔离(isolcpus)+ IRQ 绑定可把中断从关键业务核上赶走。
八、常见误区
- 误区 1:『中断里打印个 printk 调试没事』。printk 内部要拿锁、可能唤醒 klogd,在中断里高频调用会显著增加延迟甚至死锁。ftrace 的 trace 事件才是中断路径的安全打点方式。
- 误区 2:『下半部用哪种都行,反正都是延迟执行』。tasklet 里调用了会睡眠的函数(如 mutex_lock、kmalloc(GFP_KERNEL) 的回收路径)会直接触发 BUG;选错下半部类型是驱动 crash 的高频原因。
- 误区 3:『共享 IRQ 随便返回 IRQ_HANDLED』。handler 必须如实返回 IRQ_HANDLED(是我)或 IRQ_NONE(不是我);谎报会让内核无法定位中断风暴源,触发『nobody cared』后内核会愤怒地禁用整条 IRQ 线。
- 误区 4:『NAPI 是网卡特性,普通驱动学不到东西』。NAPI 的『第一事件中断、后续批量轮询』模式是通用的高频事件处理范式:io_uring 的轮询模式、高性能定时器框架、hrtimer 的 slack 合并都在用同一思想。
- 误区 5:『把中断绑到一个核上性能最好』。绑定单核只在低负载下减少缓存抖动;高负载下单核中断打满会出现『这个核 100% si 软中断、其他核围观』的经典瓶颈,正确做法是按队列数绑多核或交给 irqbalance 动态调整。
九、参考文献
- J. Corbet, A. Rubini, G. Kroah-Hartman (2005). Linux Device Drivers (3rd ed.), Chapter 10: Interrupt Handling. O'Reilly.
- Linux kernel documentation: Documentation/core-api/genericirq.rst 与 Documentation/IRQ.txt。
- J. Corbet (2006/2023). What happened to tasklets? 及 NAPI 相关系列文章, LWN.net。
- Red Hat Enterprise Linux 文档: Network Interface Card Performance — RSS, IRQ affinity, and NAPI tuning。
- W. Mauerer 著,郭旭译. 专业 Linux 内核架构 第 5 章:中断与下半部机制。