Linux 内核下半部机制深度工程:Softirq、Tasklet、Workqueue 全链路解析与生产调优
在 Linux 内核的异步处理体系中,"下半部"(Bottom Half)机制是解决中断处理与系统性能矛盾的核心设计。理解 Softirq、Tasklet、Workqueue 的底层实现与适用场景,是内核驱动开发和高性能系统调优的必备技能。本文将从硬件中断触发一路追踪到底层调度、生产环境中的经典性能陷阱,并给出基于实际案例的调优策略。
一、为什么需要下半部机制
1.1 中断处理的时钟周期困境
当网卡收到数据包时,硬件通过中断线通知 CPU,CPU 会跳转到中断处理函数(ISR)。在中断上下文中,以下限制极为严格:
- 不能睡眠/调度:中断上下文没有进程描述符
current,一旦调用可能睡眠的函数(如kmalloc(GFP_KERNEL)、mutex_lock),将导致内核崩溃 - 执行时间必须极短:中断处理期间会关闭本 CPU 的中断响应(取决于实现),长时间关中断会导致其他设备中断延迟增大,丢失数据
- 重入限制:同类型中断在处理期间通常被屏蔽顶半部(Top Half)的核心工作是 MASK 中断源 → 最小操作 → 触发下半部 。真正的数据搬运、协议栈处理、用户态通知都应该交给下半部。
1.2 经典时间分割模型
硬件中断发生
│
▼
┌──────────────────────┐
│ 顶半部 (Top Half) │ ← 硬件中断上下文,关本CPU中断
│ - 读取寄存器确认中断 │
│ - ACK 设备中断 │
│ - 将数据指针传给BH │
│ - 触发软中断 │
└──────────────────────┘
│
▼
┌──────────────────────┐
│ 下半部 (Bottom Half)│ ← 开中断(或软中断上下文),可睡眠
│ - 数据搬运 │ - 复杂计算
│ - 协议栈处理 │ - 内存分配
│ - 唤醒等待队列 │ - VFS 操作
└──────────────────────┘
Linux 内核从 2.3 版本起逐步演化出了三代下半部机制:BH(全局链表,已被废弃)、Tasklet(基于 Softirq)、Workqueue(基于内核线程)。加上底层的 Softirq 本身,构成了今天完整的下半部体系。
二、Softirq:下半部的基石
2.1 数据结构与注册
Softirq 是内核中优先级最高的下半部机制,系统预定义了 10 种软中断类型(从 Linux 6.x 起):
// include/linux/interrupt.h
enum {
HI_SOFTIRQ = 0, // 高优先级 tasklet
TIMER_SOFTIRQ, // 定时器
NET_TX_SOFTIRQ, // 网络发送
NET_RX_SOFTIRQ, // 网络接收 —— 最常见瓶颈
BLOCK_SOFTIRQ, // 块设备 I/O 完成
IRQ_POLL_SOFTIRQ, // 中断轮询
TASKLET_SOFTIRQ, // 普通 tasklet
SCHED_SOFTIRQ, // 调度器时钟 tick
HRTIMER_SOFTIRQ, // 高精度定时器
RCU_SOFTIRQ, // RCU 回调
NR_SOFTIRQS
};
每种软中断通过 open_softirq() 在静态编译时注册处理函数:
// net/core/dev.c
open_softirq(NET_RX_SOFTIRQ, net_action);
open_softirq(NET_TX_SOFTIRQ, net_tx_action);
// block/blk-softirq.c
open_softirq(BLOCK_SOFTIRQ, blk_done_softirq);
2.2 触发与执行路径
// 触发软中断
raise_softirq(NET_RX_SOFTIRQ); // 设置 per-CPU 变量中的 pending 位
// raise_softirq 实际实现(kernel/softirq.c)
// 1. local_irq_save — 保护 pending 位操作
// 2. __raise_softirq_irqoff(irq) — per-CPU pending |= (1 << irq)
// 3. 若不在中断嵌套中,调用 raise_softirq_irqoff 检查是否需要进入 ksoftirqd
关键点:Softirq 的处理发生在三个时机:
- 中断返回时(
irq_exit()→invoke_softirq()):顶半部处理完毕后立即检查 pending 位 - 系统调用返回用户空间时(
exit_to_user_mode_loop()) - ksoftirqd 内核线程被唤醒时:ksoftirqd 是 per-CPU 的守护线程,负责在软中断"风暴"时承接溢出
2.3 执行循环深度解析
// kernel/softirq.c
static __u32 pending_last_bit;
asmlinkage __visible void __softirq_entry(struct pt_regs *regs)
{
unsigned long old_irqs;
old_irqs = local_irq_save();
// 遍历 pending 位图
while ((softirq_bit = ffs(pending))) {
struct softirq_action *h;
unsigned int vec_nr;
int prev_count = 0;
--softirq_bit;
vec_nr = softirq_bit;
h = softirq_vec[vec_nr];
// 清除 pending 位
__this_cpu_write(vec_pending, 0);
// 执行处理函数
do {
if (h->action) {
h->action(h); // ← 真正的处理函数
...
}
h++;
prev_count++;
} while (prev_count < 2); // 允许一定重入
}
...
local_irq_restore(old_irqs);
}
重要特性:同一 Softirq 可以在不同 CPU 上同时执行(这是与 Tasklet 的最大区别),这意味着软中断处理函数必须是严格可重入的,所有状态必须通过每 CPU 变量或锁保护。
三、Tasklet:最常见的驱动程序接口
3.1 Tasklet 的 Softirq 本质
Tasklet 并不是独立的机制,它直接基于 HI_SOFTIRQ 和 TASKLET_SOFTIRQ 两个软中断实现:
// kernel/softirq.c
static void tasklet_action(struct softirq_action *a)
{
struct tasklet_struct *list;
local_irq_disable();
list = __this_cpu_read(tasklet_vec.head);
__this_cpu_write(tasklet_vec.head, NULL);
__this_cpu_write(tasklet_vec.tail, &__this_cpu_read(tasklet_vec.head));
local_irq_enable();
while (list) {
struct tasklet_struct *t = list;
list = list->next;
// 检查签名与状态
if (tasklet_trylock(t)) {
if (!atomic_read(&t->count)) { // 未被 disable
// 清除 TASKLET_STATE_RUN → 允许其他 CPU 执行此 tasklet
if (!test_and_clear_bit(TASKLET_STATE_SCHED, &t->state))
BUG();
t->func(t->data);
tasklet_unlock(t);
continue;
}
tasklet_unlock(t);
}
// 重新触发
local_irq_disable();
...
raise_softirq_irqoff(TASKLET_SOFTIRQ);
}
}
3.2 关键特性:串行化执行
Tasklet 有以下严格保证:
- 同一个 Tasklet 不会在两个 CPU 上并发执行(通过
TASKLET_STATE_SCHED标志位保证) - Tasklet 始终在调度它的 CPU 上执行(通过 per-CPU 链表保证)
- Tasklet 内部执行在软中断上下文,不可睡眠
// 定义与初始化
struct tasklet_struct my_tasklet;
void my_tasklet_handler(unsigned long data)
{
// 处理数据
}
// 静态初始化
tasklet_init(&my_tasklet, my_handler, (unsigned long)dev);
// 调度执行
tasklet_schedule(&my_tasklet); // 添加到当前 CPU 的 tasklet_vec 链表
tasklet_hi_schedule(&my_tasklet); // 使用 HI_SOFTIRQ,更高优先级
// 控制
tasklet_disable(&my_tasklet); // 等待完成后禁用
tasklet_enable(&my_tasklet); // 重新启用
tasklet_kill(&my_tasklet); // 等待并清理(设备上调用时用)
3.3 驱动中的经典使用模式:NAPI 收包
// 网卡驱动收包示例
static int my_napi_poll(struct napi_struct *napi, int budget)
{
struct my_priv *priv = container_of(napi, struct my_priv, napi);
int work_done = 0;
// 从硬件接收最多 budget 个包
while (work_done < budget) {
struct sk_buff *skb = my_rx_packet(priv);
if (!skb)
break;
// 上传到网络协议栈
skb->protocol = eth_type_trans(skb, priv->netdev);
netif_receive_skb(skb);
work_done++;
}
if (work_done < budget) {
// 全部处理完,退出 NAPI 轮询模式
napi_complete_done(napi, work_done);
// 重新开启中断
my_enable_rx_irq(priv);
}
return work_done;
}
// 中断处理函数(顶半部)
static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
struct my_priv *priv = dev_id;
// 确认中断、ACK 设备
my_ack_irq(priv);
// 关闭中断,切换到 NAPI 轮询模式
my_disable_rx_irq(priv);
// 调度 NAPI —— 本质上就是一个 tasklet
napi_schedule(&priv->napi);
return IRQ_HANDLED;
}
四、Workqueue:唯一可睡眠的下半部
4.1 从 Softirq 到进程上下文的本质跨越
Workqueue 是 Linux 下半部体系中唯一允许睡眠的机制。它利用内核工作线程(kworker)把处理任务从软中断上下文切换到进程上下文:
中断 → raise_softirq → ksoftirqd 检测到需要 workqueue
↓
唤醒 kworker/uX:Y 线程
↓
进程上下文:可睡眠、可调度
4.2 核心数据结构
// include/linux/workqueue.h
struct work_struct {
atomic_long_t data;
struct list_head entry;
work_func_t func;
};
struct delayed_work {
struct work_struct work;
struct timer_list timer;
struct workqueue_struct *wq;
int cpu;
};
// 工作队列本身
struct workqueue_struct {
struct list_head pwqs; // 所有 pwq 链表
struct pool_workqueue __percpu *cpu_pwqs; // per-CPU 的工作池链接点
struct pool_workqueue *numa_pwq; // NUMA 最近的 pwq
...
};
层叠关系:
Workqueue → Pool Workqueue (pwq) → Worker Pool → Worker Thread (kworker)
每个 CPU 上存在两个默认的工作队列池:
BOUND:绑核的 worker,适合需要严格 CPU 亲和的场景UNBOUND:在 NUMA 节点内浮动,适合需要并行但不需要严格绑核的场景
4.3 工作项的生命周期
// 定义工作处理函数
void my_work_handler(struct work_struct *work)
{
// 在进程上下文中执行
// 允许睡眠!
struct my_work *mw = container_of(work, struct my_work, work);
mutex_lock(&mw->lock); // OK — 可睡眠
kmalloc(size, GFP_KERNEL); // OK — 可能睡眠
msleep(10); // OK
copy_from_user(...); // OK — 可能睡眠
mutex_unlock(&mw->lock);
}
// 方式一:静态初始化
DECLARE_WORK(my_work, my_work_handler);
// 方式二:动态初始化
INIT_WORK(&my_work, my_work_handler);
// 方式三:延迟执行
INIT_DELAYED_WORK(&my_dwork, my_dwork_handler);
queue_delayed_work(system_wq, &my_dwork, msecs_to_jiffies(100));
// 调度工作
schedule_work(&my_work); // 入队到 system_wq
schedule_work_on(cpu, &my_work); // 指定 CPU
// 等待完成
flush_work(&my_work); // 同步等待此工作完成
cancel_work_sync(&my_work); // 尝试取消并等待
4.4 自定义工作队列(生产推荐)
默认的 system_wq 虽然方便,但在高负载场景中会与系统其他工作争抢 worker。生产环境强烈推荐为关键模块创建专用工作队列:
// 创建专用工作队列(Linux 6.x 推荐方式)
struct workqueue_struct *wq;
// 方式一:创建普通绑定工作队列
wq = alloc_ordered_wq_name("my-mod/%s", "rx_ring0", 0);
// 方式二:创建工作队列并设置属性
struct wq_data wq_data = { .name = "my-mod-tx", .cpu = 0, .idle = HZ };
wq = alloc_workqueue("my-mod/wq", WQ_UNBOUND, 4, &wq_data);
// 方式三:带权重的可并发工作队列
wq = alloc_workqueue("my-mod/percpu",
WQ_HIGHPRI | WQ_CPU_INTENSIVE,
0);
// 调度到专用队列
queue_work(wq, &my_work);
queue_work_on(cpu, wq, &per_cpu_work);
关键属性标志:
| 标志 | 含义 | 生产场景 |
|---|---|---|
WQ_UNBOUND |
不绑定 CPU,NUMA 节点内浮动 | 高吞吐量、并行计算 |
WQ_HIGHPRI |
使用高优先级 worker 池 | 实时性要求高的网络处理 |
WQ_CPU_INTENSIVE |
不计入并发预算 | 长时间 CPU 密集任务 |
WQ_MEM_RECLAIM |
处理内存回收工作 | 内存压力下的回收路径 |
WQ_FREEZABLE |
系统休眠时冻结 | 不需要热管理的设备 |
五、三代机制对比与选型决策
5.1 核心差异表
| 特性 | Softirq | Tasklet | Workqueue |
|---|---|---|---|
| 上下文 | 软中断(softirq) | 软中断(softirq) | 进程(kworker) |
| 是否可睡眠 | 否 | 否 | 是 |
| 同类型并发 | 多 CPU 同时执行 | 单 CPU 串行 | N/A(work不同) |
| 执行 CPU | 触发 CPU | 调度时 CPU | 可能迁移 |
| 创建方式 | 静态编译注册 | 静态/动态初始化 | 按需调度 |
| 延迟 | 最低 | 低 | 最高(受调度器影响) |
| 适用场景 | 高频硬实时 | 驱动中通用下半部 | 需要睡眠/计算密集 |
5.2 选型决策流程图
需要下半部处理?
├── 能否接受睡眠?
│ ├── 能 → Workqueue
│ └── 不能 → 继续判断
├── 同一类型需要多 CPU 并发?
│ ├── 是 → Softirq(需要注册为专门协议栈处理)
│ └── 否 → Tasklet
├── 需要延迟执行?
│ └── → delayed_work
└── 需要等待可调度线程?
└── → Workqueue
六、生产环境中的性能陷阱与调优实战
6.1 陷阱一:"Softirq Storm" 导致 RCU Stall
现象:10G/25G 甚至更高速率的网卡收包时,NET_RX_SOFTIRQ 占用 CPU 90%+,ksoftirqd 持续运行,用户进程饥饿,最终触发 RCU stall。
根因分析:每收到一批包就触发一次软中断 → 处理函数回收 budget 个包后退出 → 新到的包又触发 → 软中断被重复触发形成风暴。
解决方案:NAPI + net.core.netdev_budget + net.core.netdev_budget_usecs 联合调优:
# 每次 poll 最多处理的包数(默认 300,提高到 600)
sysctl -w net.core.netdev_budget=600
# 每次 poll 允许的最大微秒(默认 2000,根据负载调整)
sysctl -w net.core.netdev_budget_usecs=8000
# 使用 net.core.dev_weight 控制每个网卡的权重
更深层的解决方案:
// 使用 XDP 在驱动层直接处理,完全绕过 softirq
// drivers/net/ethernet/intel/ice/ice_xdp.c
static int napi_poll(struct napi_struct *napi, int budget)
{
// 先通过 XDP 程序过滤/转发大部分流量
ice_xdp_run_prog(priv);
// 仅剩余流量进入 Softirq 路径
work_done = ice_clean_rx_irq(rx_ring, napi, budget);
...
}
6.2 陷阱二:kworker 优先级抖动导致实时性丧失
现象:使用 Workqueue 处理音频编解码、电机控制等实时任务时,出现周期性的延迟抖动(从 <100μs 跳到 >1ms)。
根因分析:
- kworker 线程的 nice 值为 0,与高优先级
SCHED_FIFO线程相比更容易被抢占 - CPU-intensive 任务(如
WQ_CPU_INTENSIVE不当使用)霸占 worker pool - cgroup CPU 控制器未隔离 kworker
解决方案:层次化隔离
# 1. 使用 WQ_HIGHPRI 创建高优先级工作队列
echo 1 > /sys/bus/workqueues/my_wq/high_priority
# 2. 设置 kworker 的良好值(nice 到 -10)
# 通过 WQ_HIGHPRI 自动处理
# 3. cgroup 级别为 kworker 隔离
mkdir /sys/fs/cgroup/cpu/kworkers
echo "0-3" > /sys/fs/cgroup/cpu/kworkers/cpuset.cpus
echo 950000 > /sys/fs/cgroup/cpu/kworkers/cpu.cfs_quota_us # 保留 5% 给系统
# 4. 对于要求极端的场景,考虑使用 RT 线程替代 workqueue
struct task_struct *rt_task = kthread_create(rt_handler, NULL, "%s_rt", name);
sched_setattr(rt_task, &(struct sched_attr){ .sched_policy = SCHED_FIFO, .sched_priority = 80 });
6.3 陷阱三:共享 Softirq 导致的 CPU 迁移开销
现象:多队列网卡在特定队列分配下,同一个连接流在多个 ksoftirqd 间跳动,导致 cache line bouncing。
根因分析:Linux 的 RPS(Receive Packet Steering)将包分配给不同 CPU 处理,但 TCP 的四元组 hash 与硬件 RSS 哈希不一致。
解决方案:硬件队列 → CPU 亲和性绑定
# 查看当前中断分配
cat /proc/interrupts | grep eth0
# 绑定中断到特定 CPU(硬件队列到 CPU 的映射)
echo "2" > /proc/irq/128/smp_affinity # IRQ 128 → CPU1
echo "4" > /proc/irq/129/smp_affinity # IRQ 129 → CPU2
# 使用 `irqbalance` 的 exact 模式(现代内核)
systemctl enable irqbalance
# /etc/sysconfig/irqbalance 中设置 IRQBALANCE_ARGS="--deepestcache=2"
6.4 陷阱四:Tasklet 优先级反转在虚拟化环境
现象:KVM 虚拟机中运行数据库工作负载时,存储 I/O 延迟飙升。
根因分析:
- 虚拟机 exit VMEXIT 后触发中断
- 中断 bottom half 含
BLOCK_SOFTIRQ - 磁盘数据处理需要访问 virtio 队列(涉及宿主机通知)
- 但宿主机 tasklet 同样在等该队列的 responses → 循环依赖
解决方案:混合使用 Workqueue 处理 virtio 事件
// virtio-blk 驱动中的现代实现(drivers/block/virtio_blk.c)
static int virtblk_probe(struct virtio_device *vdev)
{
...
// 使用 Workqueue 代替 Tasklet 处理完成事件
INIT_WORK(&vblk->config_work, virtblk_config_changed);
vblk->config_workqueue = alloc_workqueue("virtio-blk-config", 0, 0);
...
}
七、监控与可观测性
7.1 Softirq 监控
# 查看各 CPU 的软中断计数
cat /proc/softirqs
# CPU0 CPU1 CPU2 CPU3
# HI: 3142 2987 3011 3102
# TIMER: 158234 145892 147221 150334
# NET_TX: 523 487 512 501
# NET_RX: 892374 1203345 1100234 982345
# BLOCK: 12345 11234 13456 12567
# IRQ_POLL: 0 0 0 0
# TASKLET: 2341 2198 2245 2301
# SCHED: 234567 223456 225678 229012
# HRTIMER: 1523 1456 1489 1512
# RCU: 345678 334567 338901 340123
# 实时监控脚本
watch -n1 "cat /proc/softirqs | awk 'NR==1{print} /^NET_RX/{sum=0; for(i=2;i<=NF;i+++=\$i; print \$1, sum}'"
7.2 Workqueue 深度监控
# 列出所有工作队列
cat /sys/bus/workqueue/devices/*/name
echo > /sys/bus/workqueue/devices/my_wq/debug # 启用调试
# 查看各个 worker 的状态
cat /proc/<pid>/stack # kworker 线程的调用栈
echo t > /proc/sysrq-trigger # 触发 sysrq 查看所有 CPU 任务栈
# 使用 bpftrace 监控 workqueue 入队延迟
bpftrace -e '
kprobe:__queue_work {
@start[arg0] = nsecs;
}
kretprobe:__queue_work /@start[arg0]/{
@us = hist((nsecs - @start[arg0]) / 1000);
delete(@start[arg0]);
}
'
7.3 使用 eBPF/BCC 工具进行生产级追踪
#!/usr/bin/env python3
# bcc-trace-softirq.py —— 统计各类软中断的延迟分布
from bcc import BPF
prog = """
#include <linux/interrupt.h>
#include <linux/smp.h>
BPF_HASH(start, u32, u64);
BPF_HISTOGRAM(dist, u64);
// 追踪 softirq 入口
TRACEPOINT_PROBE(irq, softirq_entry) {
u32 id = bpf_get_smp_processor_id();
u64 ts = bpf_ktime_get_ns();
start.update(&id, &ts);
return 0;
}
// 追踪软irq 出口
TRACEPOINT_PROBE(irq, softirq_exit) {
u32 id = bpf_get_smp_processor_id();
u64 *tsp = start.lookup(&id);
if (tsp == 0) return 0;
u64 delta_us = (bpf_ktime_get_ns() - *tsp) / 1000;
start.delete(&id);
// 按类型分桶
u64 vec = args->vec;
u64 key = (vec << 56) | (delta_us & 0xFFFFFFFFFFFFFFULL);
dist.atomic_increment(key);
return 0;
}
"""
b = BPF(text=prog)
print("Tracing softirq latency... Ctrl-C to exit")
try:
b["dist"].print_linear_hist("latency (us)")
except KeyboardInterrupt:
pass
八、Linux 6.x 的新演进
8.1 tasklet 在 5.13+ 的逐步隔离
Linux 5.13 引入了 tasklet_disable_nosync() 和 tasklet_enable() 的重新设计,目的让 tasklet 在处理过程中不再无条件阻止其他 CPU 调度同一 tasklet。实际效果:
- 在 SMP 系统上,同一 tasklet 可以同时在不同 CPU 上运行
- 不再保证同一 CPU 串行化
这意味着驱动程序需要更加注意内部锁的设计。
8.2 io_uring 对 Workqueue 的影响
随着 io_uring 普及,越来越多的异步 I/O 通过提交队列(SQ)而非 workqueue 直接通知完成:
// io_uring 的已经证实:通过 fixed buffers + 同步提交模式
// 可以完全绕过 workqueue,直接在内核线程(固定 kthread)中处理
struct io_uring ring;
io_uring_queue_init(32, &ring, IORING_SETUP_SQPOLL);
// 提交 I/O 请求
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_read(sqe, fd, buf, len, offset);
io_uring_submit(&ring);
// 完成事件通过 CQ 直接读取,或等待 kthread 复制到 userspace
这实质上把 Workqueue 的角色部分替代了,但对于仍然依赖睡眠的复杂路径,Workqueue 的地位不可替代。
8.3 实时性增强 WQ_RT 标志(提议中)
社区中讨论为 Workqueue 引入 WQ_RT 标志,让关键 worker 线程采用 SCHED_FIFO/SCHED_DEADLINE 策略,从根本上解决延迟抖动。目前仍在 patch review 阶段。
九、总结与最佳实践
将 Softirq、Tasklet、Workqueue 的选型浓缩成几条生产铁律:
- 中断只处理硬件:顶半部只做 ACK + 状态读取,100% 的数据处理交给下半部
- 高频小包避免 Softirq Storm:配合 NAPI 轮询模式,netdev_budget 动态调整
- 不可重入逻辑用 Tasklet:同一处理在多 CPU 并发需用 Softirq(通常由内核框架提供)
- 任何可能睡眠的操作走 Workqueue:文件系统操作、用户态拷贝、互斥锁获取
- 关键路径使用专用 Workqueue + WQ_HIGHPRI:避免与系统全局工作争抢
- 监控先行:通过
/proc/softirqs和bpftrace定位中断热点,不要凭经验调整 - NUMA 亲和性是高速率的基础:队列 → ksoftirqd → kworker 在同一 NUMA 节点
- 虚拟化环境混合驱动:virtio 场景避免 Tasklet,优先 Workqueue + poller
参考资料:
- Linux 内核源码:
kernel/softirq.c,kernel/workqueue.c,drivers/net/ethernet/ - 《Understanding the Linux Kernel》Chapter 4 — Interrupt Handling
- Mel Gorman —
Documentation/admin-guide/mm/numablock.rst - Linux 内核 Documentation:
core-api/irq/bottom_half.rst
作者注:在超过十年的生产环境中,我亲眼见过 40Gbps 网卡的 Softirq Storm 让整机负载瞬间飙到 200+ 的场景,也见过不当的 Workqueue 优先级导致数据库尾延迟翻 10 倍的故障。下半部机制看似古老,却是理解"为什么会慢"的最佳入口。

发表评论 取消回复