工作队列这套机制,在 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_HZ=1000,一个 jiffy 是一毫秒,填 100 确实是 100 毫秒;但如果配的是CONFIG_HZ=250,一个 jiffy 是四毫秒,填 100 就变成了 400 毫秒,差了四倍。
所以正确的做法永远是走msecs_to_jiffies(ms)或usecs_to_jiffies(us)这类换算宏,它们在编译期就会根据CONFIG_HZ展开成合适的运算,包括向上取整的处理。反过来的jiffies_to_msecs在打日志时很好用,能把难懂的时间戳转成人能读的毫秒数。
CONFIG_HZ | 单个 jiffy | 填 100 的实际延迟 | 换算建议 |
|---|---|---|---|
| 100 | 10 ms | 1000 ms | 必须用换算宏 |
| 250 | 4 ms | 400 ms | 必须用换算宏 |
| 1000 | 1 ms | 100 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表示工作项被提交,会打出队列名、工作项地址和目标 CPU;workqueue_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 亲和性策略,那里还有不少值得琢磨的设计取舍。