操作系统内功 · 第 56 篇|下半部三件套:tasklet、workqueue 与线程化中断

中断下半部三件套演进(手绘信息图)

为什么要分上下半部

第 11 篇讲信号与中断时已经说明:中断是硬件插队抢 CPU 的手段;第 26 篇看网络软中断时,也见过了 softirq 在收包路径里的位置。这一篇把中断处理的「时间管理」讲透:内核为什么必须把一次中断切成上下两半,以及三代下半部机制各自的生存逻辑与分工线。

约束决定设计。硬中断处理函数运行时,本条中断线处于屏蔽状态(不少驱动还会关掉本地处理器的中断),期间同一条线上再来的事件只能排队等硬件缓冲。如果处理函数里做慢事——拷贝大块数据、翻协议栈、打日志、等锁——网卡丢包、串口丢字节就是必然结果。所以铁律是:上半部只做「不能等的事」:确认硬件、把数据搬进内存、登记一个待办;其余全部推迟。上半部的长短直接决定这条中断线能吃多高的吞吐,内核社区对「中断里该干什么」的审查因此出了名地严格。

「剩下的」推迟到哪里、以什么身份执行,就是三代机制的分歧点,归结为三个维度:能不能睡眠、并发度如何、延迟多低。这三个维度的组合决定了每类任务的归宿——网络收包要低延迟所以留在软中断,驱动收尾要睡眠所以进 workqueue,实时要求高的设备干脆整个线程化。下面按地基到高层的顺序把三代机制走一遍。

还要补一句常见的误解澄清:上半部短不等于中断频率可以无限高。中断本身有固定成本——保存现场、走向量、缓存污染,每秒百万次中断的固定成本足以吃掉一个核的大半算力,这类「中断风暴前夜」的负载特征是 iowait 不高但系统占比虚高。治本方向是把高频事件改用轮询或混合模式处理(内核网络栈的中断加轮询混合就是教科书案例),推迟机制只是把每次中断的成本压到最低,两者不要混为一谈。

softirq 与 tasklet:静态地基与轻量推迟

softirq 是最底层的下半部,种类在编译期固定(内核里枚举十余种:HI_SOFTIRQ、TIMER_SOFTIRQ、NET_TX、NET_RX、TASKLET、BLOCK 等),驱动不能新造。它的执行时机集中在几处:硬中断返回时的 irq_exit 路径、本地软中断被重新打开的瞬间,以及过载时丢给每 CPU 一个的内核线程 ksoftirqd——当软中断长期占住 CPU 不肯撒手,内核就把它踢给 ksoftirqd,让调度器来管。cat /proc/softirqs 可以看每种 softirq 的累计触发次数,排障时对比各 CPU 的分布能立刻看出「中断都压在哪个核上」。

softirq 的并发模型很微妙:同一种 softirq 可以在多个 CPU 上同时运行,但同一 CPU 上同一种 softirq 是串行的——NET_RX 各核并行收包,同一核上不会重入。执行循环里设了预算与时间上限(一次最多处理若干个包、超过时限就让位),防止软中断把系统饿成「永远在收包、永远不调度」。这个预算机制也解释了一个经典现象:软中断压力大的机器上,ksoftirqd 线程长期吃满一个核——那不是异常,那是内核的泄洪闸在正常工作。

tasklet 建立在 softirq 之上(借用 TASKLET 与 HI 两种软中断),弥补「驱动不能自定义 softirq」的限制:驱动用 tasklet_schedule 把一个任务排进队列,回调稍后执行。它的并发语义是同一个 tasklet 绝不会在两个 CPU 上同时执行,驱动回调因此可以不加锁,代价是该任务的吞吐上限就是一个核。老网卡、老串口驱动里大量使用;近年内核社区的方向是逐步用线程化中断替代 tasklet,新代码已不建议再写——它的位置很尴尬:比 softirq 慢、比 workqueue 又不能睡眠,两头的好处都没占到。

再补一个观察维度:softirq 的执行统计在 /proc/softirqs 里按类型与 CPU 细分。压测网络时若发现 NET_RX 集中在某一个核,那通常不是 bug,而是多队列网卡把哈希后的流固定到队列、队列中断又固定到核的自然结果——要摊开它,动的是网卡队列与中断亲和的配置,而不是怪调度器不干活。现象在调度器、根因在绑定,这条推理路径是软中断排障最常用的套路。

三类机制的选择口诀就此清晰:回调绝不睡眠且要求极低延迟,用 softirq 或 tasklet;回调要睡眠(拿互斥锁、可能阻塞的内存分配、访问总线),必须用 workqueue;既要睡眠又要可调度可控,直接线程化中断。违反第一条是真实事故来源:曾有驱动在 tasklet 回调里调用会睡眠的函数,触发「调度器在中断语境里被调用」的内核报错,机器直接 oops。

workqueue:可睡眠的推迟执行

softirq 与 tasklet 有一个先天缺陷:它们仍然运行在中断语境,不能睡眠——不能拿互斥锁、不能用可能阻塞的内存分配、不能碰文件系统、不能等总线。而很多推迟工作恰恰需要睡眠:USB 驱动要等控制器完成传输,热插拔处理要拿总线锁,日志刷盘要走块层。workqueue 就是为此而生:工作项在专门的内核线程里执行,可以随便睡眠——这正是它与前两代最本质的分工线。

现代实现(并发管理工作队列,concurrent managed workqueue)不再让每个驱动自建线程,而是由内核维护一组按 CPU 分池的 worker pool,外加一个不绑定 CPU 的 unbound 池;驱动用 INIT_WORK 与 queue_work 提交工作项,用 schedule_delayed_work 延迟执行,用 flush_work 等待完成。bound 池保留缓存亲和,适合与特定 CPU 状态强相关的活;unbound 池允许任务在核间漂移,对批处理型任务更友好,也天然被 cgroup 的 CPU 带宽控制管住——「延迟敏感绑核、重活进 unbound」是内核子系统内部自己都在遵守的配比。

workqueue 的运维面也值得一提:ps -ef | grep 'kworker' 里成群的 kworker 线程就是它。平时它们大多处于空闲状态,出现 kworker 持续吃 CPU 时,用 perf 或对应跟踪工具看它在跑哪个工作项,通常能定位到某个驱动的热路径。另一个常见坑是工作项里再排队自己(自我重排实现轮询),配 合 flush 不当会造成死等——工作项里「先 flush 自己再重排」这类写法要格外小心。

工作队列还有两个工程细节值得记录。一是并发语义:默认同一个工作项不会并发执行(任何时刻只有一个执行者),需要并行要拆成多个工作项;二是定制队列:延迟敏感的场景可以申请 bound 或 unbound 的专用队列,把某类工作与其他队列的拥塞隔离开——内核子系统与高端驱动大量使用这招,效果是「一类工作堵了,别的照常跑」。

运维侧对工作队列的日常观测也有抓手:工作项堆积会表现为 kworker 线程长期占核,用性能剖析工具抓一把栈就能看到具体在跑哪个驱动的工作函数;队列类型在线程命名与绑定关系里有迹可循,看到异常线程先归类再深挖。内核把「谁在替驱动干活」都摆在进程表里,ps 一把就能看见,这是内核可观测性的老传统。

线程化中断与实时性

request_threaded_irq 把中断一次性拆成两段:快速 handler 在硬中断语境跑(确认硬件、必要时屏蔽中断),thread_fn 则在一个普通的可调度内核线程里跑。驱动注册一次,内核替你把「唤醒线程」的胶水写好。这对实时性的意义在于:中断线程是可调度的——能设优先级、能被更高优先级的实时任务抢占、能被 cgroup 管住 CPU 配额;硬中断却凌驾于调度器之上,来了就是来了,想控制都无从下手。把中断从「不可控的闪电」变成「可调度的线程」,是实时系统调优的前提。

线程化中断还有一个硬性理由:I2C、SPI 这类总线控制器本身就是可睡眠的。触摸芯片中断到达后要读寄存器拿坐标,而读寄存器要过 I2C 总线——在硬中断里做这件事是直接违例。现代触摸、传感器、电源管理芯片驱动几乎都是同一个形状:硬中断里只做「确认加唤醒」,真正读总线、处理数据的活全在 thread_fn 里。再配合 IRQF_ONESHOT 标志,中断线会保持屏蔽直到线程处理完才重新打开,避免处理中途被再次触发搅乱状态。

想全局体验线程化,可以在启动参数里加 threadirqs,让尽量多的中断处理走线程;cat /proc/interrupts 里能看到对应条目,ps 里也能找到名字与中断对应的内核线程。实时内核(PREEMPT_RT)走得更远:绝大多数硬中断都被线程化,中断延迟从「不可控」变成「可测量、可约束、可打分」——这也是通用内核与实时内核最外观可见的行为差异。

验证与调优的动作顺手记下:线程化中断在 /proc/interrupts 里的条目不再按 CPU 计数(活儿在可调度的线程里干),改用 ps 查看对应名字的内核线程,用 chrt 或 renice 调它的调度策略与优先级;实时场景把关键中断线程提到实时优先级、把其他负载压到普通优先级,就是通用内核上能做到的最接近实时的形态。开机参数 threadirqs 影响的是「哪些中断允许线程化」,不是全部——个别驱动显式拒绝,这与它上半部的实现有关。

中断风暴与 IRQ 亲和性的运维手段

运维现场最常见的两个问题是中断绑核与中断风暴。前者先看 cat /proc/interrupts:每行是一条中断在各 CPU 上的累计次数。把高频中断(比如万兆网卡的中断)绑到专用核,能改善缓存局部性并让业务核不被打扰;写法是往 /proc/irq/N/smp_affinity_list 写 CPU 编号(N 是中断号),比如 echo 2 > /proc/irq/24/smp_affinity_list 把 24 号中断钉在 2 号核;掩码格式的 smp_affinity 文件则接受十六进制位图。绑核前必须确认 irqbalance 守护进程的状态——它默认会周期性重新均衡中断,不商量就把你的手工配置冲掉,要么停掉它要么用它的配置文件接管。

中断风暴是另一类事故:设备故障或驱动缺陷导致中断线持续触发,CPU 被吃满,系统看似活着实则瘫了。内核有自保机制——同一条中断线短时间内异常过猛时,内核会打印类似 "nobody cared" 或 "Disabling IRQ" 的日志并自动关闭该线,此后对应设备失聪。应急路径是固定的:dmesg 里找肇事中断号,对照 /proc/interrupts 找到设备,重载驱动或换插槽;云主机上遇到过 virtio 类设备风暴时,临时把中断迁到空闲核顶住,再等平台侧处理。中断类问题的第一现场永远是 /proc/interrupts 与 dmesg,两个文件对着看。

顺着网络栈再提一句分工:中断绑核解决的是「中断在哪个核上跑」,而多队列网卡的分发靠硬件队列与 RSS 哈希,软件层还有 RPS/XPS 一类补充机制——它们属于网络调优的语境,第 26 篇讲网络软中断时已有铺垫,这里不展开。要记住的是层次:硬件先决定事件去哪个队列,亲和性再决定队列的中断去哪个核,两层都对,性能才对。

绑核效果的验证方法很简单:改亲和前后各采一段 /proc/interrupts 的增量,目标中断线的计数迁移到指定核即生效;若计数纹丝不动,先查 irqbalance 是否改了回去,再确认该中断线是否支持亲和设置。另外提一句常见误配:把所有中断都绑到同一个核只是把分散的争用聚成一团,绑核的目的是专核专用,不是集中管理——高频中断与业务负载各得其所,才是绑核的本意。

小结

上下半部的分野是内核中断处理的骨架:上半部抢时间确认硬件,下半部按需选择执行语境。三代机制的演进史就是「推迟的活越来越像普通进程」的历史——softirq 完全在中断语境、tasklet 换来串行安全、workqueue 换来可睡眠、线程化中断则把整条中断线交给调度器治理。

选型的判断只有一个维度组:回调要不要睡眠、要求多低的延迟、要不要被调度器管控。softirq 留给内核协议栈,驱动侧的新代码在 tasklet 与线程化中断之间几乎总该选后者,需要睡眠就交给 workqueue。违反语境约束(在中断里睡眠)是内核编程的头号违例,报错信息极具辨识度,但最好在写代码时就避开。

把三代机制放进时间轴看也很有意思:softirq 是早年为网络栈设计的老将,tasklet 是驱动繁荣期的产物,并发管理工作队列与线程化中断则是多核时代与实时需求的回应。机制在换代,上下半部的分野却从未动摇——中断里只留不能等的,其余交给可调度的执行体,这条原则从单核时代沿用到今天,未来大概率也不会变。

运维侧两个抓手:/proc/interrupts 是中断分布的第一现场,绑核与 irqbalance 的关系要先想清楚再动手;中断风暴靠内核的自保日志定位,处置路径固定。实时性的下一步在实时内核与线程化程度,但通用内核上,绑核加优先级已能解决大多数「中断抢业务核」的投诉。

下一篇转向功耗语境:CPU 不是永远满频的,P-state 与 C-state 两本账怎么算,governor 怎么选,数据库主机锁 performance 惯例的来龙去脉。

← 返回资讯列表

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?