上半部 / 下半部机制与内部实现:softirq / tasklet / workqueue
这是 interrupts(内核中断处理总纲)的拆分篇之一,讲两件事:内核怎么把中断的急活和重活拆开(机制)、上半部到底怎么把重活交给下半部(内部实现)。本文由原「下半部机制」「下半部内部实现」两篇合并而成。配套拆分篇:分类体系与设计考量 · 处理流程 · 开销量化。
第一部分:为什么要拆(机制概述)
一个中断 handler 执行期间,通常同类中断被屏蔽(甚至全部关中断),这段时间:
- 不能响应新的同类中断 → handler 拖太久会丢事件(比如网卡下一个包来了没人收)。
- 别的高优先级事情也被耽误。
但有些中断要干的活不少(处理一个网络包要走完协议栈)。矛盾就是:handler 要尽量短,可活儿又不少。
内核的解法——把中断处理劈成两半:

- 上半部(硬中断 handler):在关中断/屏蔽同类中断的状态下运行,只做必须立刻做的最少事:应答设备、把关键数据拿走、标记"还有活要干",然后火速返回。
- 下半部:上半部登记的"待办",在开中断的环境里稍后执行,干那些耗时的活。这样"关中断的窗口"被压到最短。
一句话:上半部"快接快应答",下半部"慢工出细活"。网卡就是典型——上半部只把包从网卡搬进内存队列并应答,下半部(NET_RX 软中断)才慢慢走协议栈。这正是
mpstat里%soft高常常是网络繁忙的原因。
下半部的三种机制:softirq / tasklet / workqueue
内核实现"下半部"有三种机制,区别在能不能睡眠和并发性:
| 机制 | 运行上下文 | 能睡眠? | 并发性 | 典型用途 |
|---|---|---|---|---|
| 软中断 softirq | 中断上下文 | 不能 | 同一种可在多核并行 | 网络收发(NET_RX/NET_TX)、块设备、定时器——性能最关键、内核预定义的固定几种 |
| tasklet | 中断上下文(基于 softirq 实现) | 不能 | 同一个 tasklet 不会并行(串行化) | 一般设备驱动的下半部,比 softirq 好用、无需静态注册 |
| 工作队列 workqueue | 进程上下文(内核线程 kworker) | 能睡眠 | 可调度 | 需要睡眠/阻塞、或耗时长的活(如访问慢设备、分配大内存) |

为什么中断上下文不能睡眠:睡眠意味着"挂起当前任务、切换到别的任务",但中断上下文不属于任何进程(它是借用被打断进程的栈临时跑的),没有可挂起的实体,睡下去就再也回不来 → 死锁/卡死。所以 softirq/tasklet 里严禁调用可能睡眠的函数(如加互斥锁、分配可能触发回收的内存)。如果确实要睡眠,只能把活丢给 workqueue,让 kworker 内核线程在进程上下文里干。
Linux 预定义的软中断类型
| 软中断 | 优先级 | 用途 |
|---|---|---|
HI_SOFTIRQ | 0(最高) | 高优先级 tasklet |
TIMER_SOFTIRQ | 1 | 定时器到期处理 |
NET_TX_SOFTIRQ | 2 | 网络包发送 |
NET_RX_SOFTIRQ | 3 | 网络包接收(最高频的软中断) |
BLOCK_SOFTIRQ | 4 | 块设备 IO 完成 |
TASKLET_SOFTIRQ | 6 | 普通 tasklet |
SCHED_SOFTIRQ | 7 | 调度相关 |
HRTIMER_SOFTIRQ | 8 | 高精度定时器 |
RCU_SOFTIRQ | 9 | RCU 回调处理 |
softirq 的执行时机
softirq 在三个位置被检查和执行:
- 中断返回路径(
do_IRQ返回前):最常见,搭中断返回的便车。 local_bh_enable:当代码重新开启下半部时,检查并执行 pending softirq。ksoftirqd内核线程:如果 softirq 太多、前两个位置处理不完,内核将它们推给每个核的ksoftirqd/<n>线程慢慢消化——这时在top里会看到ksoftirqd吃 CPU、%soft高。
NAPI(网络中断合并)
高速网络下,如果每个包都来一次中断,中断就会把 CPU 淹没(中断风暴)。NAPI 的做法是:第一个包来了触发中断后,关掉该网卡后续中断,改用轮询(poll)批量收包,收完再开中断——中断 + 轮询混合,大幅降低高负载下的中断开销。

第二部分:下半部的内部实现(三种交接方式)
上半部到底怎么把重活"交接"给下半部?答案是两种"接力棒"方式——在中断上下文能跑的下半部(softirq/tasklet)用位图+队列交接,需要进程上下文的下半部(workqueue)用唤醒内核线程交接。
raise_softirq 内部机制:一个位图就搞定
raise_softirq() 和 __raise_softirq_irqoff()(已关中断时调用)做的事非常简单——只在当前 CPU 的 per-CPU 位图上置一个 bit:
__raise_softirq_irqoff(TIMER_SOFTIRQ) 实际做的事:
1. 读取 per-CPU 变量 softirq_pending(this_cpu)
2. 把第 TIMER_SOFTIRQ 位设为 1
3. 写回 softirq_pending(this_cpu)
没有复杂的队列、
没有动态分配、
没有任何可能失败的操作
关键设计:
- 无锁:每个 CPU 只改自己的
softirq_pending,不需要任何锁 - 极快:就是一条
or指令(外加一个wakeup_softirqd检查),几个 CPU 周期 - 无开销:没有内存分配、没有链表操作,不会失败
do_softirq 如何执行待办任务
当代码在三个执行点(中断返回 / local_bh_enable / ksoftirqd)发现 local_softirq_pending() != 0 时,调用 __do_softirq():
__do_softirq():
1. 重置本地 pending 位图为 0(用 xchg 原子交换)
2. for (pending != 0; prio = 0..NR_SOFTIRQS; prio++) :
if (pending & (1 << prio)):
调用 softirq_vec[prio].action(softirq_vec[prio])
// → 在中断上下文中执行!不能睡眠
3. 如果执行完又有新的 pending(说明下半部又触发了下半部):
→ ksoftirqd 被唤醒,剩下的活交给它
(防止下半部无限递归导致栈溢出或饿死别的任务)
注册 softirq:open_softirq(NET_RX_SOFTIRQ, net_rx_action) 在启动时把 net_rx_action 函数指针填入 softirq_vec[NET_RX_SOFTIRQ].action。softirq 是编译期静态分配的,不能动态增删类型——这就是为什么普通驱动不用 softirq 而用 tasklet/workqueue。
tasklet_schedule 内部机制:基于软中断的轻型队列
tasklet 是对 softirq 的高级封装——底层走 TASKLET_SOFTIRQ 或 HI_SOFTIRQ,但加了自动序列化。
数据结构:
struct tasklet_struct {
void (*func)(unsigned long); // 下半部函数
unsigned long data; // 传给 func 的参数
unsigned long state; // 状态位:
// bit 0 (TASKLET_STATE_SCHED): 已排入队列
// bit 1 (TASKLET_STATE_RUN): 正在执行
struct tasklet_struct *next; // 链表节点
};tasklet_schedule(t) 做的事:
tasklet_schedule(t):
1. 检查 t->state & TASKLET_STATE_SCHED
→ 如果已设置,直接返回(不会重复排队)
2. 设置 t->state |= TASKLET_STATE_SCHED
3. 把 t 头插到 per-CPU 链表 tasklet_vec[cpu]
4. raise_softirq_irqoff(TASKLET_SOFTIRQ)
→ 通知 softirq 框架:这 CPU 有 tasklet 要跑执行时(tasklet_action 被 do_softirq 调用):
tasklet_action():
1. 取出当前 CPU 的 tasklet_vec 链表
2. 遍历链表,对每个 tasklet:
if (t->state & TASKLET_STATE_RUN):
→ 跳过!(已被其他 CPU 执行中)
else:
设置 t->state |= TASKLET_STATE_RUN
调用 t->func(t->data)
清除 TASKLET_STATE_RUN | TASKLET_STATE_SCHED
tasklet 和 softirq 的本质关系:tasklet 就是一堆
tasklet_struct挂在 per-CPU 链表上,执行时机由TASKLET_SOFTIRQ这个软中断号触发。driver 开发者不需要知道softirq_vec[]的存在——tasklet_schedule()和tasklet_init()两个 API 就够。
queue_work 内部机制:从中断上下文跳转到进程上下文
工作队列和 softirq/tasklet 的根本区别:下半部跑在内核线程 kworker 里,有完整的 task_struct,可以睡眠。
数据结构:
struct work_struct {
work_func_t func; // 下半部函数
struct list_head entry; // 链表节点
};
// per-CPU 工作队列
struct pool_workqueue {
struct worker_pool *pool; // 绑定的 worker 池
struct list_head worklist; // 待办 work 链表
};queue_work(wq, work) 做的事:
queue_work(wq, work) / schedule_work(work): // schedule_work 默认用 system_wq
1. 把 work 尾插入 pool_workqueue->worklist 链表
2. 检查是否有空闲的 kworker 线程
→ 有:唤醒它
→ 没有且允许:创建新的 kworker
3. 返回kworker 执行:
kworker 线程主循环(进程上下文!):
1. 从 pool_workqueue->worklist 取出一个 work_struct
2. 调用 work->func(work)
→ 这里可以 sleep、拿 mutex、分配大内存
3. 检查是否有新 work 入队
→ 有:继续执行
→ 没有:让出 CPU,进入睡眠
关键差异:
queue_work从上半部返回前,work 还没执行——只是排了队、唤醒了 kworker。kworker 什么时候开始跑,由调度器决定。而 softirq/tasklet 在中断返回时就可以执行(还在中断上下文里)。
三种下半部交接方式总结

上半部和下半部之间的数据交接:通常是通过上半部申请一块内存(或从 ring buffer 预分配中取一块)→ 填数据 → 挂到共享队列 → 下半部从队列中取出来处理。数据本身不经过 raise_softirq / tasklet_schedule / queue_work 传递——这些 API 只传递"有活要干"这个信号。
NAPI 的 handoff 具体流程(最完整的实例)
以 Intel igb 网卡驱动为例,看上半部怎么把数据"交接"给下半部:

这就是为什么 NAPI 在高负载下效率高:一次中断 → 批量收 64 个包 → 如果还没收完再继续。从"一包一中断"变成了"一中断收一批"。而整个 handoff 的关键数据结构就是
napi_struct——它在igb_msix_ring(上半部)和igb_poll(下半部)之间传递,包含了收包的 ring buffer 指针和当前处理状态。
ksoftirqd:当下半部太多时的安全阀
如果 do_softirq 循环 10 次后还有 pending(新包不断涌来),或者检测到执行时间过长,下半部不能无限循环——会把调度器饿死、让别的进程得不到 CPU。这时内核的"泄洪"机制:
irq_exit()
→ __do_softirq()
→ 第 11 次循环还有 pending?
→ wakeup_softirqd() ← 唤醒 ksoftirqd/n
→ ksoftirqd 在进程上下文执行 run_ksoftirqd()
→ 和 do_softirq 一样遍历 softirq_vec[]
→ 但可以被抢占、可以被调度器换下去%soft 高的时候看什么:
# ksoftirqd 就是 %soft CPU 时间的来源
ps aux | grep ksoftirqd
# 查看每个核的 ksoftirqd
ls /proc/*/comm | grep ksoftirqd
# 查看网络软中断是否在 ksoftirqd 里跑
cat /proc/softirqs | grep NET_RX一次网卡收包的完整中断流程(全部串起来)
把上半部、下半部、调度、系统调用全部串起来看:

这串流程把本仓库几条线连了起来:中断(处理流程) → 唤醒进程 → 加入运行队列、等调度(scheduling) → 进程 recv 返回是一次系统调用(syscall)。
一句话总结:上半部负责"快接快应答",把"有活要干"的信号通过位图(softirq)、per-CPU 链表(tasklet)或唤醒内核线程(workqueue)三种方式交给下半部;下半部在开中断的环境里干重活。NAPI 则是"关中断+批量轮询"的组合,让网络在高负载下不被中断风暴淹没。