
工作队列这套机制在 linux 内核里算得上是驱动开发者每天都要打交道的老朋友而 schedule_delayed_work 又是其中出场率最高的接口之一。凡是需要在中断上下文之外、过一小段时间再干活的场景比如按键去抖、网卡链路状态轮询、传感器周期采样、掉电延时关闭几乎都能看到它的身影。它的好处很直接工作在进程上下文运行允许睡眠、允许拿互斥锁、允许调用可能阻塞的分配接口比 tasklet 和软中断自由得多同时它又带一个延迟参数能替代一部分定时器的职责。这篇文章我打算把它从里到外讲透从 API 语义、数据结构、参数换算一直讲到自建工作队列、并发控制、模块卸载时的清理顺序最后再把我这些年踩过的坑一条条摊开。不管你是刚上手写第一个字符设备驱动的新人还是已经写过几万行内核代码的老手应该都能从中捞到点东西。1. 先把定位搞清楚什么时候才轮得到它出场刚接触内核那会儿我对 tasklet、timer、workqueue 这三个东西经常分不清写代码时凭感觉挑一个能跑就行结果在一个需要睡眠的采集逻辑里塞了 tasklet一调用就可能出问题。后来被现实教育了几次才明白选哪个不是风格问题而是硬约束问题。1.1 三类延迟执行机制的边界在哪里软中断和 tasklet 跑在中断上下文这个上下文是原子的不能睡眠不能调mutex_lock不能调可能阻塞的内存分配也不能对用户空间做拷贝。它们的存在意义是处理中断下半部里那些必须快、不能等的事情比如网络收包队列的初步处理。定时器回调同样运行在软中断上下文所以timer_list的回调里也有一样的限制。这三个的共同点是执行时间必须极短任何可能引起调度的操作都是禁区。工作队列则完全不同它把工作项交给内核线程worker去执行worker 是正经的进程上下文可以睡眠、可以被抢占、可以调度。这条规则延伸出一个很实用的判断标准如果你的延迟任务里需要访问用户空间、需要拿信号量或互斥锁、需要调用copy_to_user、需要读 I2C 或 SPI 这类可能睡眠的总线那基本只能选工作队列。反过来如果任务只有几十行纯粹的寄存器读写和内存操作用 tasklet 反而更轻量因为省掉了线程调度和上下文切换的开销。delayed_work相当于在工作队列的基础上叠了一层定时器。它的内部结构里就嵌了一个timer_list延迟时间到点后由内核的定时器回调把工作项真正挂到工作队列上。所以它同时具备可以睡眠和延迟执行两个特性是驱动里做周期性任务的默认选择。1.2 一次 schedule_delayed_work 背后发生了什么调用schedule_delayed_work(dwork, delay)这一行看起来平平无奇但它触发的动作链条其实不短。内核首先检查这个delayed_work内部的定时器回调函数是不是delayed_work_timer_fn这是初始化时被写死的如果对不上说明这个结构体没被正确初始化过内核会直接告警返回。这一步就是很多人遇到的work item 没初始化却去排队的现场保护。检查通过之后如果delay是 0内核会走一条捷径直接把工作项挂进队列完全不碰定时器等于退化成queue_work。如果delay大于 0内核就把定时器的超时时间设成jiffies delay然后激活定时器此后工作项处于等待状态工作队列那边还完全不知道它的存在。等时钟中断推进到那个时间点定时器回调触发它调用queue_work_on把dwork-work挂进目标 CPU 的工作队列链表这时候 worker 才有机会把它捞出来执行。这里有个容易被忽略的点延迟入队到定时器到期之间工作项是挂起但未入队的状态cancel_delayed_work在这个阶段取消是能成功的因为它本质上是del_timer_sync加一次队列检查。理解了这条链路后面讲取消和刷新时的各种行为就都顺理成章了。2. 数据结构与 API把 delayed_work 的家底翻一遍写内核代码最怕的就是知其然不知其所以然接口签名背得滚瓜烂熟一出问题就抓瞎。所以这一节我想先把这个结构体的内部构造摊开看看。2.1 work_struct 与 delayed_work 的血缘关系struct work_struct是所有工作项的基础里面主要是一个atomic_long_t data和一个函数指针func。data这个字段很巧妙它被复用来存放工作项当前所在的工作队列指针、CPU 编号、以及各种状态标志位所以一个工作项在哪排队这件事是存在自己身上的。struct delayed_work则是在work_struct外面套了一层第一个成员就是struct work_struct work后面跟着一个struct timer_list timer再加一个wq指针和一个cpu字段。因为work是第一个成员所以dwork-work和(struct work_struct *)dwork指向同一块内存指针互转是安全的。这个特性带来一个实用技巧如果你在某个地方只拿到了work_struct *用container_of就能把外层的delayed_work捞回来反过来也一样。但要特别警惕的是schedule_work系列接口传进去的必须是普通work_struct而schedule_delayed_work系列必须传delayed_work。这两者的函数签名在编译期就会拦住大部分错误但如果有人图省事做强制类型转换把一个纯work_struct传给schedule_delayed_work内核会去访问一块根本不存在的 timer 区域后面就是随机的内存踩踏这种 bug 往往要到系统跑几十分钟之后才爆出来排查成本极高。2.2 初始化宏的两个选择运行时还是编译期初始化delayed_work有两个宏用法差别和适用场景都不太一样。INIT_DELAYED_WORK(dwork, my_func)是运行时初始化适合把delayed_work作为设备私有结构体的成员、在 probe 阶段动态申请的场景。它做的事比较实在先把内部的work用INIT_WORK初始化一遍把func填成你给的函数然后把内部定时器的回调固定为内核自己的delayed_work_timer_fn再设置好定时器标志。另一个是DECLARE_DELAYED_WORK(name, func)它是静态定义同时完成变量定义和初始化适合做全局的、生命周期跟模块一致的周期性任务。这个宏在编译期就完成了所有设置累加初始化不用额外调用函数模块加载路径更干净。注意用INIT_WORK去初始化一个delayed_work然后用schedule_delayed_work提交这是绝对不能做的。反过来用INIT_DELAYED_WORK初始化再拿dwork.work去做schedule_work倒是合法的因为work成员确实被正确初始化过了只是那条路径上的延迟语义会丢失。2.3 入队、取消、刷新一张表把语义对齐下面这张表是我自己写代码时常放在旁边对照的重点在是否阻塞等待这一列因为这直接关系到会不会在原子上下文里踩雷。接口目标队列是否等待执行完成典型使用位置schedule_work(work)system_wq否probe、中断下半部schedule_delayed_work(dwork, delay)system_wq否周期任务、去抖queue_work(wq, work)指定 wq否需要独立队列时queue_delayed_work(wq, dwork, delay)指定 wq否独立队列加延迟cancel_delayed_work(dwork)不涉及否中断上下文可调cancel_delayed_work_sync(dwork)不涉及是只能进程上下文flush_delayed_work(dwork)不涉及是强制立即执行并等待flush_workqueue(wq)指定 wq是卸载整个队列前排空cancel_work_sync(work)不涉及是普通工作项的同步取消关于返回值schedule_delayed_work和queue_work都返回bool含义是这次入队是否真的把工作项新挂进去了。如果工作项已经在队列里待着再调一次会返回false并且不会重复入队。这一点在周期性任务里非常有用你可以靠它避免同一个工作项被多次排队导致执行次数翻倍。cancel_delayed_work的返回值语义稍微绕一点返回true表示成功取消了还没执行的工作项返回false说明工作项要么已经在执行中要么根本没排队。注意这里的false不代表出错只是说明我没能拦住它所以如果你的资源在 work 函数里会被访问光靠cancel_delayed_work是不够的必须换成同步版本。3. 动手写一个能跑的最小实例光看 API 说明容易飘还是得落到代码上。下面这个例子我简化过保留了完整的骨架直接可以编译进去跑。3.1 驱动骨架与工作项的声明#include linux/module.h #include linux/workqueue.h #include linux/jiffies.h #define POLL_INTERVAL_MS 500 struct my_dev { struct delayed_work poll_work; int counter; }; static struct my_dev *gdev; static void my_poll_work(struct work_struct *work) { struct my_dev *dev container_of(to_delayed_work(work), struct my_dev, poll_work); dev-counter; pr_info(poll run %d times, jiffies%lu\n, dev-counter, jiffies); /* 重新排下一次实现周期任务 */ schedule_delayed_work(dev-poll_work, msecs_to_jiffies(POLL_INTERVAL_MS)); } static int __init my_init(void) { gdev kzalloc(sizeof(*gdev), GFP_KERNEL); if (!gdev) return -ENOMEM; INIT_DELAYED_WORK(gdev-poll_work, my_poll_work); schedule_delayed_work(gdev-poll_work, msecs_to_jiffies(POLL_INTERVAL_MS)); pr_info(my module loaded\n); return 0; } static void __exit my_exit(void) { cancel_delayed_work_sync(gdev-poll_work); kfree(gdev); pr_info(my module unloaded\n); } module_init(my_init); module_exit(my_exit); MODULE_LICENSE(GPL);这段代码里有几个点是刻意安排的。counter和delayed_work放在同一个结构体里work 函数通过container_of(to_delayed_work(work), ...)把上下文拿回来这是驱动里最标准的写法避免了用全局变量带来的多实例问题。to_delayed_work这个宏本质就是container_of专门用来从work_struct *反查到delayed_work *。真正关键的顺序在my_exit里先cancel_delayed_work_sync再kfree。如果反过来先把gdev释放掉而此刻 worker 正在执行my_poll_work并且正在读写dev-counter那就是教科书级别的 use-after-free。更麻烦的是 work 函数结尾还会重新schedule_delayed_work等于在已经释放的内存上重新排队一个定时器后果不可预测。3.2 延迟时间换算别直接写 jiffies 数值schedule_delayed_work的第二个参数单位是 jiffies不是毫秒也不是微秒。很多新手看到参数名写着delay就随手填个 100 以为是一百毫秒实际取决于CONFIG_HZ的配置。如果内核配的是CONFIG_HZ1000一个 jiffy 是一毫秒填 100 确实是 100 毫秒但如果配的是CONFIG_HZ250一个 jiffy 是四毫秒填 100 就变成了 400 毫秒差了四倍。所以正确的做法永远是走msecs_to_jiffies(ms)或usecs_to_jiffies(us)这类换算宏它们在编译期就会根据CONFIG_HZ展开成合适的运算包括向上取整的处理。反过来的jiffies_to_msecs在打日志时很好用能把难懂的时间戳转成人能读的毫秒数。CONFIG_HZ单个 jiffy填 100 的实际延迟换算建议10010 ms1000 ms必须用换算宏2504 ms400 ms必须用换算宏10001 ms100 ms也建议用换算宏还有一个绕不开的现实问题延迟精度。jiffies 的粒度本身就是 tick 级别再叠加内核的定时器合并timer coalescing和timer_slack机制delay设成 5 毫秒实际执行时间在 6 到 8 毫秒之间是正常的。省电场景下如果系统进入了 tickless 空闲唤醒时间还可能被进一步推迟。所以延迟工作项适合做大约隔多久做一次的事情如果你的场景对时间精度要求到微秒级那就该考虑 hrtimer 配合工作队列的组合用高精度定时器做触发具体逻辑还是丢给 work 去做。3.3 周期性任务的自重新入队写法上面例子里用的是执行完再排下一次的方式也就是在 work 函数末尾重新调用schedule_delayed_work。这种写法的好处是不用管队列里有没有积压每次执行完才排下一次天然避免了同类任务堆积。还有一种写法是在外部按固定节拍排比如另外用一个定时器每隔固定时间queue_delayed_work一次。这种写法的隐患是如果某次 work 执行时间超过了排队的间隔那么队列里就会积压越来越多的实例内存和 CPU 都会被拖垮。我在一个采集项目里就吃过这个亏采样间隔设了 100 毫秒但某次读到异常硬件状态导致 work 函数里做了重试一跑就是几百毫秒结果待处理的工作项像雪球一样越滚越大最后直接触发系统响应变慢。想用固定节拍又怕堆积可以靠返回值来判断schedule_delayed_work返回false说明这个工作项已经在队列里了那就直接跳过本次排队。这种剩余排队的丢弃策略在多数监控类任务里都是可以接受的因为最新的状态总会覆盖旧状态。但要注意不能把返回值当成上一条执行失败来判断它表达的只是排队状态。另外提一句工作项从挂起到执行之间不要试图用flush_delayed_work去逼它立刻跑虽然它确实能取消定时器并立即入队执行但这个接口会阻塞等待只能用在进程上下文。在中断处理函数里调用它内核会直接报调度错误。3.4 模块退出时的清理顺序模块卸载阶段的清理顺序值得单独拉出来讲因为这是最容易在客户现场炸掉的地方。我总结的顺序是先切断外部触发源再同步取消工作项最后释放其依赖的资源。具体来说假设你的设备还注册了一个中断处理函数而这个中断里会调用schedule_delayed_work那么在模块退出时第一件事应该是free_irq把中断注销掉。理由很简单如果先取消工作项、再注销中断中间那段时间里中断还是活着的它完全有可能在cancel_delayed_work_sync返回之后又排一个新的工作项进去等你下次释放设备结构体时就出事了。第二步才是cancel_delayed_work_sync它保证两件事延迟定时器被删掉以及如果工作项正在某个 worker 上执行函数会一直等到执行完毕才返回。只有它返回之后你才能确定 work 函数不会再碰你的设备结构体。第三步再释放内存、注销设备节点、销毁自建的工作队列。如果自建了工作队列销毁顺序是先对所有工作项做同步取消再调destroy_workqueue。别指望destroy_workqueue能替你处理正在跑的工作项它只会把队列结构本身清理掉正在执行的那部分会直接跑在已经释放的结构上。提示如果cancel_delayed_work_sync调用后卡住不返回八成是死锁了。最常见的情况是 work 函数内部又调用了cancel_delayed_work_sync去取消自己或者在持有某把锁的时候调用了它而 work 函数也正想拿这把锁。往下看第 5 节有详细的排查路径。4. 进阶自建工作队列与并发控制系统自带的工作队列用起来最省事但它不是万能药。当你的任务有特殊的执行要求时就得自己建一个。4.1 system_wq 够用时别急着自建schedule_delayed_work背后用的是system_wq这是一个所有内核代码共享的全局工作队列。它的优点是省资源不需要你申请和销毁队列结构也不需要额外考虑模块卸载时的队列清理。绝大多数的去抖、心跳、周期性上报任务挂在system_wq上完全没问题。但共享队列也意味着你和别的子系统在抢 worker 线程。system_wq默认的并发上限是每个 CPU 256 个活跃工作项正常情况下够用但如果你有一些执行时间很长的任务比如一次要几百毫秒的固件校验它就可能把 worker 占住让其他程序提交的短任务排在后面等着。这种场景就该自建队列把慢任务隔离出去。判断标准很简单任务单次执行时间超过几毫秒、或者需要特定的并发度、或者对执行顺序有严格要求就该自建。自建队列的另一个好处是可观测性。alloc_workqueue的第一个参数是队列名这个名字会出现在 ftrace 的 trace 输出里一眼就能看出是哪个模块的哪个队列在干活排查问题时省下的时间远超建队列的成本。4.2 alloc_workqueue 常用标志位怎么选alloc_workqueue(name, flags, max_active)三个参数第二个参数是各种WQ_标志位的按位或选错了会有比较隐蔽的后果。WQ_UNBOUND表示工作项不绑定到提交它的那个 CPU而是交给一个全局的无绑定线程池。它的实际意义是当某个 CPU 被高优先级任务占满时无绑定的工作项还能被别的 CPU 处理不会一直饿着。如果你有一批耗时较长的任务又不希望它们拖慢绑定的那个 CPU加这个标志比较合适。代价是会带来额外的调度开销和跨 CPU 的缓存失效。WQ_MEM_RECLAIM比较特殊它保证这个队列在系统内存紧张、正在进行内存回收的时候仍然能跑起来工作项。如果你的工作项本身参与了内存回收路径比如块设备驱动的写回、文件系统的元数据刷盘就必须带上这个标志否则会形成互相等待的死锁内存回收需要工作项执行而工作项因为内存回收占着资源而无法被调度。反过来说普通驱动里不要随便加这个标志因为每个带WQ_MEM_RECLAIM的队列都会预留自己的 worker 线程属于实打实的资源占用。WQ_HIGHPRI让队列里的工作项以更高的 nice 值运行适合对延迟敏感的场景。max_active控制每个 CPU 上能同时运行多少个工作项默认给 0 表示用系统默认值如果你想做严格的串行化执行把它设成 1 就行这样同一个队列里的工作项永远不会并发跑。标志位作用什么时候用代价WQ_UNBOUND不绑定 CPU耗时任务、CPU 忙时仍要能跑调度开销、缓存失效WQ_MEM_RECLAIM内存紧张时仍能执行内存回收路径上的驱动预留 worker 资源WQ_HIGHPRI高优先级运行延迟敏感任务可能抢占其他任务WQ_FREEZABLE系统休眠时冻结挂起前需清理状态的设备唤醒时机需配合关于max_active取值还有个小细节设为 1 时整个队列是严格串行的这种模型适合做有状态机的任务比如协议栈的分包处理你不需要自己去加锁队列本身已经保证了顺序。但如果某个工作项内部又提交了同一队列的工作项并等待它完成串行度设成 1 就会直接死锁这是必须避开的模式。4.3 重复入队与并发竞态的处理工作项能不能被同时提交多次这是个很实际的问题。答案是同一个工作项在同一时刻只能在一个队列里待着重复提交不会生成两个实例。内核在queue_work里会检查工作项的pending状态位如果已经置位直接返回false走人。这个机制天然保护了同一个工作项不会并发执行两次。但并发问题并没有完全消失危险出在同一个工作项的不同阶段和多个工作项之间。举个我自己遇到过的例子一个处理上报的 delayed_work第一次触发时开始读硬件读的过程中设备又触发了一次中断中断里调了schedule_delayed_work因为此时工作项正在执行、pending位已经清掉这次提交是成功的于是第二个实例排进了队列。等第一个实例执行完第二个紧接着就开始两个实例访问同一份状态数据如果不加锁就会互相踩。解决办法是在 work 函数入口加互斥锁或者干脆改成自重新入队的模型让任务永远只有一个实例在流动。还有一类坑跟cancel_delayed_work的异步特性有关。假设你的 work 函数里会访问dev-buf卸载路径里调了cancel_delayed_work之后立刻kfree(dev-buf)。因为cancel_delayed_work不等待执行中的任务这个kfree完全可能发生在 work 函数正读dev-buf的中间直接就是一个 use-after-free。这个坑我在早期项目里翻过至少两次后来索性定了个规矩只要资源会在 work 函数里被访问卸载时一律用同步版本。5. 排错现场那些让我熬夜的坑接口用法讲完了但真正让人头疼的部分在出问题的时候。这一节我把常见症状、排查手法和自己踩过的坑集中整理一下。5.1 常见问题速查表现象可能原因排查方向模块卸载时卡死同步取消时死锁检查 work 内部是否取消自身或持锁等待工作项从不执行初始化宏用错grep 确认用的是INIT_DELAYED_WORK执行次数比预期多重复排队未检查返回值打印schedule_delayed_work返回值卸载后系统随机崩溃释放顺序错误确认先取消再释放中断先注销延迟时间偏差很大直接填了裸数字检查是否用msecs_to_jiffies加载时报定时器告警timer 回调被改过确认没有手动覆盖内部定时器工作项执行时间越来越长队列积压看 ftrace 里排队到执行的间隔关于卡死那个问题展开说一下。cancel_delayed_work_sync内部会做一次循环先尝试取消定时器然后检查工作项是不是正在执行如果是就等它跑完然后再检查一遍有没有被重新排进去。如果这个工作项是自重新入队的它在执行末尾又排了一次那么同步取消会继续取消这个新的实例一直循环到彻底干净为止。这个循环如果永远结束不了说明工作项在被取消的同时总能被重新排进队列常见于 work 函数里有个 while 循环不断重排或者中断源源不断地投递新任务。这时候要先断掉重排的来源再调用取消。另一个容易被忽视的是锁的方向。假设你的卸载函数持有dev-lock然后调用cancel_delayed_work_sync而 work 函数开头也要拿dev-lock此时它已经排进队列并且正准备执行。这就形成了一个典型的 ABBA 死锁卸载函数等 work 结束work 等卸载函数放锁。解决办法是取消前先放锁或者干脆在 work 函数里用trylock配合快速退出。5.2 用 ftrace 看工作项的生命周期光靠pr_info打日志能看到的只有进入和退出中间的排队、激活、执行三个节点是黑盒。ftrace 里的 workqueue 事件能把这条时间线完整画出来。先确认 debugfs 已经挂上然后打开几个关键事件mount -t debugfs none /sys/kernel/debug cd /sys/kernel/debug/tracing echo 1 events/workqueue/enable echo 0 tracing_on echo 1 tracing_on cat trace | head -50输出里主要关注四种事件workqueue_queue_work表示工作项被提交会打出队列名、工作项地址和目标 CPUworkqueue_activate_work表示工作项进入了可执行状态这是延迟任务从定时器转到队列的标志workqueue_execute_start和workqueue_execute_end分别标记工作函数的进入和返回。把提交时间减去激活时间就是它实际等待的时长如果这个值远大于你设定的延迟说明队列压力大或者 worker 不够用。这套手法我用得最多的场景是排查延迟任务偶尔漏执行。有一次线上反馈某个心跳任务每隔几秒就丢一次日志上看不出来。用 ftrace 一抓就发现workqueue_queue_work有记录但workqueue_activate_work一直没来顺着看下去发现是系统中的定时器压力太大加上这个任务设置的时间精度太细被合并推迟了。最后把任务的延迟粒度从 10 毫秒放宽到 200 毫秒问题就没再出现。5.3 我踩过的几个典型坑第一个坑是初始化宏用混。早年我写一个传感器驱动图省事在 probe 里对整个设备结构体做了memset清零然后用INIT_WORK初始化了delayed_work里的work成员提交时用的是schedule_delayed_work。跑起来偶尔能工作但系统跑一阵子就会在别的地方莫名崩溃。后来才反应过来内部的定时器结构完全没被初始化timer-function是空的内核在入队时触发了告警但那个告警在某些配置下不一定能及时看到。改成INIT_DELAYED_WORK之后一次就稳了。这个坑现在的教训是看代码时只要看到schedule_delayed_work就往前翻它的初始化确认是INIT_DELAYED_WORK或DECLARE_DELAYED_WORK。第二个坑是在 work 函数里调用了会睡眠很久的接口。有一次我在一个 delayed_work 里做固件升级逐字节写 Flash 还带重试单次执行了将近两秒。结果是这个 work 占着system_wq的 worker 不放同一时间系统中别的地方提交的任务全部排在后面整个设备看起来像卡住了。后来改成自建队列加WQ_UNBOUND把长任务隔离出去同时限制max_active为 1长任务对系统的影响就消失了。第三个坑比较隐蔽跟消费返回值有关。我写过一个事件到达时立即处理没事件就等一会儿再看的逻辑代码大概是在 work 里判断有没有数据有就处理没有就自己重新排一次 100 毫秒。这样处理本身没问题但问题是我在别处也调用了schedule_delayed_work试图唤醒它两次排队的返回值都没检查。结果就是同一个工作项在队列里被排了两次一次是自排的一次是外部唤醒的处理逻辑就被连着执行了两遍重复上报了一次事件。后来把外部唤醒改成检查返回值返回false就跳过重复问题解决。第四个坑跟flush_delayed_work有关。我曾经在一个 ioctl 处理里用它做同步想让用户态调用后立刻看到最新状态思路是调用它把延迟任务提前跑掉。功能上确实实现了但后来发现这个 ioctl 在高频调用时系统响应明显变慢因为每次调用都阻塞等待一轮完整的任务执行。这个接口本身没有错是我把它用在了不该用的地方。后来改成在 work 函数里加一个完成标志加等待队列用户态用poll或阻塞读来等结果响应反而更好。写到这里我在实际项目中的体会是schedule_delayed_work这套机制本身设计得足够健壮内核里那些看起来古怪的行为比如不检查返回值导致重复排队、不同步取消就释放内存几乎都能在文档的角落里找到解释。真正让代码稳定的往往不是更复杂的技巧而是把三条规矩钉死初始化宏不能混用时间参数必须走换算宏资源释放前必须同步取消。这三条守住了绝大多数诡异的崩溃和卡死都会自动消失。如果后面还想继续深挖可以顺着 workqueue 的并发管理机制往下看 worker pool 的动态伸缩或者研究一下WQ_UNBOUND在 NUMA 机器上的 CPU 亲和性策略那里还有不少值得琢磨的设计取舍。