
凌晨两三点被一个藏得很深的延迟尖刺叫醒这种事干内核的应该都经历过。那一次我盯着perf sched的输出看到kworker/3:1每隔几十微秒就在隔离 CPU 上醒来一次而这块 CPU 本来是我用isolcpus精心圈起来跑实时任务的。顺藤摸瓜查下去源码里不过是一行再普通不过的schedule_work()——中断里顺手把活丢进系统默认工作队列这在十年前可能没人会多看一眼但在 CPU 隔离面前它足以把实时预算一点点啃干净。所以最近几年内核里对 workqueue API 的这轮“十年一遇”级重构我举双手赞成。核心变化不是把某个函数改个名字那么简单而是整个使用模型的转向从隐式的全局调度走向显式的队列选择。这篇文章我会先把schedule_work()为什么会毁掉 CPU 隔离的机制讲透再聊聊新 API 到底改了什么最后给出一套能直接抄的迁移和验证流程。想搞清楚自己代码里有没有埋雷的或者在真实系统上跟 kworker 噪声搏斗过的这篇应该对胃口。1. 先还原一下事故现场隔离 CPU 上为什么会冒出 kworker1.1 一次典型的延迟尖峰排查过程先说我实际遇到过的场景。设备有 4 个大核CPU 2 和 CPU 3 通过内核启动参数做了隔离专门跑对时延敏感的数据面任务。业务方反馈最坏情况延迟从几十微秒恶化到了将近一毫秒。一开始怀疑中断亲和性没配好但检查完/proc/irq/*/smp_affinity发现中断已经被绑定到 CPU 0 和 CPU 1 上。那就继续看调度器。cyclictest在隔离 CPU 上跑着perf sched record -g抓了一小段结果发现罪魁祸首是kworker/3:1。这个 kworker 在 CPU 3 上被反复唤醒执行的是一个网络驱动的工作函数。进一步用 ftrace 的 workqueue 事件追踪能看到整个链条某块网卡的中断虽然落在 CPU 1但中断处理过程中调用了schedule_work()把工作项直接排队到了当前 CPU 的system_wq池里而system_wq是 per-CPU 的工作项从哪个 CPU 入队就在哪个 CPU 的 worker pool 里执行。问题就在这。哪怕你不希望隔离 CPU 被内核线程打扰只要中断、软中断或者定时器回调在隔离 CPU 上触发了schedule_work()这个工作项就注定在隔离 CPU 上执行。kworker 不是普通用户态任务isolcpus对它的约束极其有限它照样被调度到那个 CPU 上然后该跑的活照样跑。最终表现就是你认为已经“隔离”的 CPU实际上仍然在悄悄执行内核异步任务延迟尖刺一个接一个。1.2 schedule_work 到底把工作项排到哪了从 API 语义上看schedule_work()不过是一个简写int schedule_work(struct work_struct *work) { return queue_work(system_wq, work); }关键在于这个system_wq。它是内核启动时创建的一个 per-CPU 工作队列背后是每个 CPU 上各自的 worker pool 和 kworker 线程。queue_work()在入队时并不做什么复杂的负载均衡它会优先把工作项放到当前 CPU 对应的 pool 里让当前 CPU 的 kworker 去消费。理解这一点就明白为什么 CPU 隔离场景里它这么危险。per-CPU workqueue 的设计初衷是缓存友好、避免跨 CPU 唤醒这在普通服务器上是优点。可一旦某个 CPU 被标记隔离这个“优点”就变成缺点了你无法把它手里已经排队的活儿转交给其他 CPU。换句话讲只要隔离 CPU 上发生了会调用schedule_work()的事件该 CPU 就得自己消化这份工作不管那个工作本身是不是短小轻量。1.3 不只是驱动代码会踩这个坑在调试那一次网络延迟问题时我曾以为只是这个网卡驱动的毛病。后来把上下文放宽发现存储子系统、块设备、某些 RCU 回调路径、甚至调速器相关的代码里都有类似的模式中断/软中断里发现需要延迟处理的事图省事直接schedule_work()用queue_delayed_work()或mod_delayed_work()安排一个定时补偿任务而定时器可能正好在隔离 CPU 上到期某些驱动把schedule_work_on(CPU)明确指定到了隔离 CPU 上因为调用者本身就跑在那。这些工作项单个看都不重但叠加起来kworker 在隔离 CPU 上的唤醒频率可能达到每秒几万次。对跑实时任务或 DPDK 轮询的人来说任何一次抢占都意味着抖动都可能让一个业务超时。schedule_work()的“顺手”和“方便”在这里成了最大的风险源。2. 十年一遇的重构到底在改什么2.1 旧 API 的历史包袱隐式全局是原罪Workqueue 机制在内核里存在了非常多年早期 API 长期保持稳定这本是好事。但稳定也带来了另一个后果开发者习惯了“有个 work_struct 就丢给系统队列”的写法很少有人去思考这个工作项到底该由哪个队列、以什么并发度、在什么 CPU 亲和性下执行。这里有个不容易注意到的历史包袱system_wq是一个全局共享队列内核里成千上万个驱动都在用它。两个看似无关的子系统各自的 work 可能排进同一个 per-CPU 池互相影响对方的 worker 创建和唤醒节奏。更麻烦的是这种共享队列没有给使用者留下任何“可调性”。一个驱动想把自家 work 的 CPU 亲和性从隔离 CPU 上挪走在旧模型里基本无从下手因为system_wq不是一个你可以单独设置 cpumask 的入口。隐式全局 API 的另一个问题是审查困难。schedule_work()一行调用看不出任何策略意图。代码评审时没人会追问“你选的是哪个队列、为什么”因为 API 根本没给人选的机会。于是大量代码把策略问题藏了起来直到 CPU 隔离场景下才集中爆发。2.2 新模型的转向显式队列显式责任这次被很多人称为“十年一遇”的重构最核心的改变是把队列选择从隐式变成显式。新的编程模型要求调用方明确持有struct workqueue_struct *再通过queue_work(wq, work)这类接口入队而不是悄悄落回某个全局队列。模型上更完整的写法是struct workqueue_struct *my_wq; my_wq alloc_workqueue(mydrv_wq, WQ_UNBOUND | WQ_SYSFS | WQ_MEM_RECLAIM, 0); if (IS_ERR(my_wq)) { /* handle error */ } INIT_WORK(my_work, my_work_fn); queue_work(my_wq, my_work);这套模型下每个调用点都必须回答一个问题我为这个工作项选择了什么执行环境是 per-CPU 还是 unbound高优先级还是普通优先级是否允许在内存回收路径里使用这个“被迫思考”的过程本身就是对 CPU 隔离问题的提前规避。代码评审的收益尤其明显。以前看到schedule_work()没什么好说的现在看到queue_work(priv-wq, ...)自然能沿着priv-wq去检查这个 wq 的创建参数、cpumask 可调性、并发度设置。责任被放到了明面上。2.3 新的工作队列类型向 softirq 阶段靠拢除了显式化重构也带来了一些新的队列类型其中和 CPU 隔离关系最大的是新增的一类可在软中断上下文执行的工作队列。它对应的标志是WQ_BH这种 workqueue 允许工作项直接在 softirq 阶段执行而不必唤醒一个 kworker 进程去处理。为什么要这样做你可以把 kworker 理解成一次完整的进程唤醒、调度、执行的旅程。在nohz_full的隔离 CPU 上任何一次唤醒都可能把 CPU 从深度 idle 里拽出来或者打断正在运行的数据面任务。如果一个工作项可以在 softirq 上下文里原地执行省掉调度器参与那么它对实时流的扰动会小很多。这类队列更适合处理那些确实很快、只是不想在硬中断里做的工作。对做 CPU 隔离的人来说这是除了“显式选择队列”之外另一个值得关注的新工具。2.4 可运维性的升级WQ_SYSFS 成为调优入口再强调一个容易被忽略的变化WQ_SYSFS标志。当用alloc_workqueue()创建 wq 时带上这个标志内核会在/sys/devices/virtual/workqueue/name下暴露对应的 sysfs 入口运维可以直接改cpumask和max_active。这在以前是不可想象的。你没法对system_wq做这种精细控制它要么接受所有 CPU要么由内核内部逻辑决定 worker 的分布。而有了WQ_SYSFS一个驱动只要愿意它的工作队列就能被管理员按需绑定到一组 CPU 上。比如部署环境里有 CPU 2、3 被隔离那就把队列的 cpumask 改成0-1让异步任务全部避开隔离区。这种“运行时可调”能力对真实业务的价值远大于 API 表面上的整洁。3. 迁移实操从“随手 schedule_work”到正确的队列观念3.1 先做一次全面体检找出历史债务迁移的第一步不是改代码而是先知道自己有多少“历史债务”。在驱动或模块目录里扫一遍grep -rn -E schedule_work|schedule_work_on|INIT_WORK|INIT_DELAYED_WORK|queue_delayed_work --include*.c .拿到结果后按场景分类不要无脑替换为同一个 wq。我的分类习惯大概是这样工作类型典型触发场景推荐执行环境快速状态处理中断/软中断里的轻量善后短小的 per-CPU 私有 wq或直接考虑 BH 类工作队列延迟补偿/重试定时器驱动的重试、资源恢复私有 unbound wq避免在 per-CPU 队列里堆积CPU 密集型任务校验、加密、批量数据处理明确使用WQ_UNBOUND并配合低max_active慢速外设等待等待硬件事件、等待锁或 DMA 完成建议评估kthread_worker或其他线程化方案这个分类的意义在于工作项的执行环境应该由“延迟要求”和“执行时长”共同决定而不是由“调用点恰好在哪里”决定。以前schedule_work()把这两者混为一谈现在该拆开了。3.2 队列选型对照表如果你不确定怎么选我在实际迁移过程中常被问到“到底用 system_wq 还是自己建一个 wq”。我给出一套非常务实的决策依据如果这个 work 非常轻、非常短、调用频率低并且你能接受它跟着中断 CPU 走那么短期沿用system_wq可以但长期仍建议迁移如果这个 work 不关心在哪个 CPU 上执行只想快点被处理system_unbound_wq比system_wq更适合因为 unbound 池不会把一个队列死绑在当前 CPU 上如果你希望队列能由运维调整 CPU 范围那就必须创建私有 wq 并带上WQ_UNBOUND | WQ_SYSFS如果 work 可能出现在内存回收路径上记得加WQ_MEM_RECLAIM否则系统内存压力大时你的工作可能永远排不上如果同一类 work 的并发度必须受控比如最多同时处理 4 个那就用私有 wq 的max_active参数来限制。一个很容易踩的误区是以为自己创建了私有 wq就拥有了一组专用线程。alloc_workqueue()创建的 unbound wq 背后仍然是内核共享的 worker pool并不等于每创建一个 wq 就开辟一支专属 kworker 大军。它提供的是队列策略和并发上限的控制权而不是物理隔离。过度创建 wq同样会把 worker 线程数量推高反而制造更多调度噪声。3.3 迁移示例把一个网卡驱动从 system_wq 挪到私有 unbound wq拿一段典型代码举例。迁移前struct nic_priv { struct work_struct tx_work; ... }; static void nic_tx_handler(struct work_struct *work) { struct nic_priv *priv container_of(work, struct nic_priv, tx_work); /* 处理发送队列 */ } /* 中断或软中断里 */ schedule_work(priv-tx_work);迁移后struct nic_priv { struct workqueue_struct *wq; struct work_struct tx_work; ... }; static int nic_probe(struct pci_dev *pdev, const struct pci_device_id *id) { ... priv-wq alloc_workqueue(nic_tx, WQ_UNBOUND | WQ_SYSFS | WQ_MEM_RECLAIM, 0); if (!priv-wq) return -ENOMEM; INIT_WORK(priv-tx_work, nic_tx_handler); ... } /* 中断或软中断里 */ queue_work(priv-wq, priv-tx_work); /* 移除设备时 */ cancel_work_sync(priv-tx_work); destroy_workqueue(priv-wq);区别在哪里最明显的是现在队列的 CPU 范围可以由管理员在运行时收紧echo 0-1 /sys/devices/virtual/workqueue/nic_tx/cpumask echo 1 /sys/devices/virtual/workqueue/nic_tx/max_active如果部署环境里有隔离 CPU 2、3这一条命令就能让所有发送队列的异步工作绕开它们。旧代码里你只能对着kworker/3:1干瞪眼。3.4 迁移时容易掉进去的新坑迁移过程有一些细节看起来是小问题踩进去就是大事故。第一别在cancel_work_sync()持锁的状态下调用。cancel_work_sync()会等待工作函数执行完毕如果工作函数里又尝试获取同一把锁直接死锁。我的习惯是驱动停止服务前先置一个private-stopping标志再调用cancel_work_sync()。第二destroy_workqueue()本身就包含 flush 语义但如果你要保证某个阶段所有 work 已完成最好显式调用flush_workqueue()或针对单个 work 使用flush_work()。搞清楚这些 API 的同步语义比写对入队代码更重要。第三同一个struct work_struct不能被同时排进两个不同队列也不能在没有完成前重复初始化。重构过程中我见过有人把一个 work 同时丢进两个 wq结果工作函数在两条路径上并发执行最后数据直接损坏。如果确实需要分派到不同队列请为每种路径各自准备独立的工作项。第四对WQ_UNBOUND的max_active 0要理解清楚。在很多内核版本里max_active为 0 表示不对并发量做额外限制worker 数量由池的并发管理逻辑自动调整。它不代表默认 1。不要把配置写错还指望它按你想的方式工作。4. 迁移之后怎么用实测证明 CPU 隔离真的保住了4.1 搭一个能复现“恶化”的测试环境没有对比的迁移是耍流氓。我会建议先搭一个能复现原先延迟问题的环境再动代码。一套典型的启动参数如下isolcpus2,3 nohz_full2,3 rcu_nocbs2,3 irqaffinity0,1这组参数的含义很直白CPU 2、3 不参与普通任务调度不接收调度时钟 tickRCU 回调节点也挪走中断默认落在 CPU 0、1。之后确认一下cat /sys/devices/system/cpu/isolated在迁移前的代码上跑一次实时性测试记录最坏延迟。比如cyclictestcyclictest -S -p99 -q -D 60s --histogram1000把迁移前的最大延迟、平均延迟、超过某个阈值的次数全部存档。这些数据就是后续改动的基准线。4.2 用 ftrace 把 kworker 噪声钉在墙上测试环境正常跑业务负载的同时打开 workqueue 相关事件追踪trace-cmd record -e workqueue:workqueue_queue_work \ -e workqueue:workqueue_execute_start \ -e workqueue:workqueue_execute_end \ -- sleep 30 trace-cmd report重点看cpu字段凡是落在 2、3 上的 workqueue 事件都是你需要消灭的对象。我们可以统计一个简单的数量变化同一负载下迁移前半小时内隔离 CPU 上的 workqueue 执行次数与迁移后的次数作对比。通常会有几个数量级的差距。如果不想依赖 trace-cmd可以用内核 debugfs 提供的 workqueue 信息ls /sys/kernel/debug/workqueue/ cat /sys/kernel/debug/workqueue/workqueues这里能看到每个 wq 名、对应 pool、以及各 CPU 上的排队情况。迁移前后对照同一个 wq能从队列侧直接确认“隔离 CPU 上没有这个 drv 的 work 再入队”。4.3 迁移后的实测结果会是什么形态我过手的一个存储驱动迁移后隔离 CPU 上的 workqueue 相关唤醒从原先每秒 3 万多次降到了几乎只有 RCU 的零星活动。实时任务的延迟分布同步改善cyclictest的最大值从 820 微秒掉到了 40 微秒以内。这不是因为我改了业务逻辑纯粹是移除了 kworker 对隔离 CPU 的反复打断。需要特别提醒的是workqueue 只是问题的一部分。如果你想追求完整隔离还需要关注这几个邻居RCU 回调靠rcu_nocbs参数剥离调度时钟 tick靠nohz_full处理硬件中断亲和性靠irqaffinity约束某些日志打印、控制台输出、watchdog 线程也都有可能踩进隔离区。我见过有人只优化了 workqueue结果延迟还是高最后发现是rcu_preempt的 callback 在隔离 CPU 上偷偷跑。所以迁移完别急着庆祝系统性验证比局部修复更重要。5. 一点心里话把“省事”留给自己还是把“可控”留给用户写驱动十几年我越来越认同一个判断你在 API 层面省下的思考最终都会变成用户在运行时付出的代价。schedule_work()就是最典型的一个。它写起来爽审查时无感上线后却让做实时系统的同事在凌晨爬起来查kworker。新 API 的“重构”看起来只是把调用方式改显式了但它逼着每个开发者做一次必要的决策我的工作项到底应该跑在哪个队列里按什么并发度允不允许用户重新指定 CPU这些决策不应该等到生产环境出问题时才被想起。如果你现在还在用schedule_work()我的建议很简单不要等上游把它彻底移除主动做一次小范围迁移。从最容易造成干扰的高频路径开始改配合WQ_SYSFS暴露 cpumask改完立刻用 ftrace 看隔离 CPU 上的事件数量。等你在真实系统上看到延迟数据的变化就会明白这次重构为什么值得被记住十年。