ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux内核delayed_work深度解析:从原理到驱动实践避坑

Linux内核delayed_work深度解析:从原理到驱动实践避坑 1. 从中断里为什么不能睡说起schedule_delayed_work 解决的到底是什么问题刚上手写驱动的同学几乎都会撞上同一堵墙调试按键驱动的时候想让 LED 亮 20ms 再灭于是很自然地在上半部里写下msleep(20)编译通过一加载就报BUG: scheduling while atomic。原因不复杂——中断处理函数运行在中断上下文它借用的是被打断那个进程的内核栈没有自己的 task_struct调度器根本不知道该把谁切出去再切回来。所以任何会睡眠的操作在这里都是非法的。schedule_delayed_work这一类接口就是给我现在不能做但过一会儿必须做而且这件事还得能睡眠这个场景准备的。它把任务丢给一个内核线程kworker由这个线程在稍后的某个时刻去执行你的回调。因为 kworker 是一个真正的进程上下文所以你的回调里可以msleep、可以拿mutex、可以kmalloc(GFP_KERNEL)、可以走 I2C/SPI 总线的同步传输这些在上半部里全部是禁区。这就是它区别于 tasklet 和内核定时器的核心价值。这篇文章面向三类人一是正在写字符设备/平台驱动需要做消抖、轮询、超时检测的嵌入式开发人员二是看内核代码时被queue_delayed_work、mod_delayed_work、flush_workqueue这一堆长得差不多的函数名绕晕的人三是想在模块里实现周期性干活又不想自己搓一个 kthread 的人。我会先把机制拆开讲清楚再给一份能直接编译加载的最小模块最后把我在实际项目里踩过的坑一条条摊开讲包括模块卸载时的崩溃、自续期 work 的幽灵重启、以及 work 内部自我取消导致的卡死。需要预先明确一点下面所有关于接口行为的描述以主线的 5.x / 6.x 内核为准如果你维护的是 3.x 甚至 2.6.x 的老 BSP部分细节比如返回值类型、workqueue 的内部实现会不一样我会在相应位置标出来。1.1 上半部与下半部的基本约束Linux 中断处理的经典切分方式是把中断分成上半部top half和下半部bottom half。上半部就是注册上去的那个中断处理函数它的职责只有一个用最快的速度把硬件状态确认掉、把数据抢出来然后赶紧返回。因为它执行期间当前 CPU 上这个中断线是被屏蔽的同类型的其他中断也会被挡住多停留一微秒都是对系统实时性的伤害。下半部则是所有可以晚点做的事情的统称。晚点做的方式有很多种但每一种的约束条件完全不同这是最容易搞混的地方。tasklet 跑在软中断上下文里依然不允许睡眠内核定时器timer_list的回调同样跑在软中断上下文也不允许睡眠而且它的到期时间精度受限于 tick 和定时器轮盘只有工作队列workqueue把回调搬到了内核线程里才真正解开了睡眠这个限制。所以选型的判断标准其实很清晰如果你下半部要做的事情里有任何一次可能睡眠的调用或者任何一把 mutex那就必须用 workqueue没有别的选择。反过来如果你的下半部只是几个简单的内存拷贝和寄存器读写用 tasklet 反而更轻量因为没有线程切换的开销。1.2 几种延后执行机制的分工把常见的几种机制放在一张表里对比选型的时候心里会踏实很多。机制执行上下文能否睡眠触发时机典型用途tasklet / 软中断软中断上下文否中断返回前或 ksoftirqd网络收包、块设备完成的后续处理timer_list软中断上下文否到期时刻简单超时、周期性打点delayed_workkworker 内核线程是到期后由工作队列执行消抖、重试退避、需要走总线的延后任务threaded IRQ专属内核线程是中断触发中断处理里有大段慢操作的场景自己写的 kthread独立内核线程是自己用wait_event等待常驻的长时间周期任务从这张表能看出delayed_work的定位它是定时器 线程上下文的组合体。delayed_work内部确实内嵌了一个timer_list到期后那个定时器回调并不直接执行你的函数而是把你的 work 塞进工作队列让 kworker 去跑。这个两段式的设计是理解它的关键后面第 2 节会详细拆。有一点要提前说清楚schedule_delayed_work本身是可以在中断上下文里安全调用的。它内部做的事情是加定时器、往队列里挂节点这些都是非睡眠操作。真正不能在中段上下文调用的是那些带_sync后缀的取消和刷新函数因为它们要等一个 work 执行完必须睡眠。这个区别非常重要很多驱动就是在上半部里调度、在下半部里取消分工明确。2. delayed_work 内部一个 work_struct 加一个 timer 是怎么跑起来的很多人用schedule_delayed_work用了好几年但从来没打开过include/linux/workqueue.h看它的结构体定义。这没关系直到有一天你遇到同一个 work 反复调度却只执行了一次或者取消之后又莫名其妙跑起来了这类问题——这时候不了解内部结构就只能靠猜。delayed_work的定义非常朴素就是一个work_struct加一个timer_list再加两个用于记录队列归属和 CPU 的字段struct delayed_work { struct work_struct work; struct timer_list timer; struct workqueue_struct *wq; /* 到期后往哪个队列塞 */ int cpu; /* 到期后在哪个 CPU 上跑 */ };这个定义直接解释了三个行为特点。第一因为内嵌了定时器所以delayed_work必须先初始化才能用而且不能拿一个普通的work_struct强转过来当delayed_work用。第二因为记住了wq和cpu所以排队时指定哪个 CPU、到期后就在哪个 CPU 上执行这个关联是在排队那一刻就锁定下来的。第三work_struct只有一个意味着同一个delayed_work在任意时刻只能待在一个队列里这也是后面重复调度返回 false的根本原因。2.1 INIT_DELAYED_WORK 到底初始化了什么初始化用的宏有两个常见写法静态定义用DECLARE_DELAYED_WORK(name, func)动态分配的结构体成员用INIT_DELAYED_WORK(dwork, func)。它们做的事完全一样把work-func设成你的回调把work-data清成初始值然后调用init_timer把内嵌定时器初始化好并且把timer.function固定设成内核自己的delayed_work_timer_fn——注意这里不是设成你的函数。这个细节挺关键的。你的回调永远不会被当作定时器回调直接调用定时器回调永远只有delayed_work_timer_fn这一个它只负责把 work 挂到队列上。真正执行你代码的是另一个路径。很多人在dmesg里看到堆栈里出现delayed_work_timer_fn以为是自己的函数调用栈出了偏差其实就是没搞清楚这个分层。注意INIT_DELAYED_WORK必须在第一次调度之前调用而且只能调用一次。如果你想复用一块已经用过的结构体不要指望重新INIT一遍就万事大吉必须先cancel_delayed_work_sync确认它已经彻底停下来。2.2 从定时器到期到 kworker 执行的完整链路把一次完整的调度过程拆成四步心里就有清晰的地图了。第一步你调用schedule_delayed_work(dwork, delay)。如果delay是 0它会直接走立即入队的路径否则它把dwork-wq、dwork-cpu记下来把内嵌定时器的expires设成jiffies delay然后add_timer。这一步结束后你的 work 处于定时器已挂上、队列里还没有的状态内核里对它没有任何 PENDING 标记。第二步时间到了内核的定时器软中断触发调用delayed_work_timer_fn。这个函数取出dwork-wq和dwork-cpu调用内部的__queue_work把dwork-work挂到对应 CPU 的工作队列里。如果在挂入的瞬间发现这个 work 已经在队列里了PENDING 位已置那就什么都不做直接返回。这就是延迟到期时 work 已经被重新调度过这种边界的处理方式。第三步目标 CPU 上的 worker pool 发现队列非空唤醒一个kworker线程。这个线程从队列里摘下工作项设置正在执行标记清掉 PENDING 位。第四步执行work-func(work)。你的代码在这里跑整个函数从头到尾都处于 kworker 的进程上下文里。连起来看delay只是什么时候被塞进队列的时间而不是什么时候开始执行的时间。从塞进队列到真正被 kworker 拿起来跑中间还要经过唤醒调度这个间隔取决于系统负载。所以对延时的精度心态要放平。2.3 为什么同一个 work 不能被重复入队工作队列的work_struct里那个unsigned long data字段低几位被用作标志位其中最重要的就是 PENDING。入队时会用原子操作尝试置位如果发现已经置上了说明这个 work 已经在某个队列里等着了函数直接返回 false 并放弃本次入队。这个设计带来两个直接后果。一是重复调度是幂等的你连调十次schedule_delayed_work(dwork, HZ)队列里也只有一个实例它只会执行一次。二是这个特性可以直接拿来做流控如果你不希望某个清理任务被重复触发就用它去重不需要额外加锁。反过来说如果你确实需要同一种处理逻辑并行跑多份那必须定义多个独立的delayed_work实例。这是工作队列里一个很常见的需求比如一个设备有多个通道每个通道都有自己的超时检测任务那就得给每个通道各自准备一个结构体。3. 几个长得像的 API入队、改期、取消、刷新该怎么选这一堆接口名字确实容易混。我的记法是按生命周期阶段分类入队阶段的函数负责让 work 进队列改期阶段的函数负责调整已经排上队的 work取消阶段负责让它别跑刷新阶段负责等它跑完。分清楚这四个阶段选择就不难了。3.1 入队三兄弟的差别schedule_delayed_work(dwork, delay)是最常用的包装它等价于queue_delayed_work(system_wq, dwork, delay)用的是系统默认的system_wq。如果你需要指定在某个 CPU 上执行用queue_delayed_work_on(cpu, wq, dwork, delay)如果你只是想在指定 CPU 上跑但队列还是默认的用schedule_delayed_work_on(cpu, dwork, delay)。需要说明的是system_wq的语义它是 per-CPU 的、非 unbound、不带WQ_FREEZABLE的默认队列。这意味着它在系统挂起流程中不会被冻结也意味着如果你在它上面塞了一个长时间跑的任务可能会影响同一 CPU 上其他模块的 work 执行。所以对于跑得比较久或者对并发度有要求的任务比较规范的做法是用alloc_workqueue自建一个队列把风险隔离开。自建队列的代码长这样/* WQ_MEM_RECLAIM允许在内存回收路径中被使用避免死锁 */ /* WQ_UNBOUND不绑定 CPU适合跑得久的任务能跨 CPU 并发 */ static struct workqueue_struct *my_wq; my_wq alloc_workqueue(my_dwq, WQ_MEM_RECLAIM | WQ_UNBOUND, 0); if (!my_wq) return -ENOMEM;第三个参数max_active传 0 表示使用默认并发度。对于 per-CPU 队列默认上限是 256历史上如此WQ_MAX_ACTIVE定义在头文件里别以为它是无限并发的。这一点后面第 5 章还有具体案例。3.2 返回值的真实语义queue_delayed_work和schedule_delayed_work的返回值语义非常统一返回true表示这个 work 之前就已经在队列里了本次调度被忽略返回false表示本次成功入队。新版本内核统一用bool老版本内核里是int但是 0/1 的含义是一致的。这个东西坑过很多人因为直觉上返回非零应该表示成功。实际恰恰相反。schedule_work也是同样的约定。所以下面这段代码是错误的/* 错误示范把返回值当成成功标志 */ if (!schedule_delayed_work(dwork, HZ)) pr_err(schedule failed\n);正确的理解是如果它返回 false说明你的调度生效了。要判断是不是已经有人在处理了才去看返回值。3.3 取消与刷新四个函数的分工取消类函数有cancel_delayed_work和cancel_delayed_work_sync。前者只是尝试把定时器摘掉并清掉 PENDING 位它不等待正在执行的 work 结束也就是说调用返回后你的回调可能还在另一个 CPU 上跑着。后者会阻塞等待直到 work 既不在队列里也不在执行中才返回所以它必须在可以睡眠的上下文里调用也就是进程上下文而且不能持有自旋锁。刷新类函数有flush_work、flush_delayed_work和flush_workqueue。它们的语义是等已经排进队列的 work 执行完但不会取消那些还没到期的延迟任务——flush_delayed_work会先把定时器改成立即到期再等它跑完flush_workqueue则是等整个队列排空。drain_workqueue更狠一些它会把队列标记成排空状态之后新提交的 work 会被拒绝直到排空结束一般用在队列即将销毁之前。函数是否等待执行完成是否可在中断上下文调用典型使用时机cancel_delayed_work否是只想阻止它下次跑不关心当前这次cancel_delayed_work_sync是否卸载模块、关闭设备前flush_delayed_work是并强制立即到期否需要确保延迟任务已经跑过一遍flush_workqueue是否队列销毁前的收尾drain_workqueue是否队列销毁前且不允许新任务进入选择原则很简单在exit或者close这种以后这个 work 绝对不能再跑的场景一律用_sync版本只在中断里做轻量防抖、后面还有机会补救的场景用非_sync版本就够了。3.4 延迟参数的换算与精度上限delay的单位是 jiffies不是毫秒也不是纳秒。直接写schedule_delayed_work(dwork, 100)意味着100 个 tick在HZ100的配置下是 1 秒在HZ1000的配置下是 100 毫秒。这种写法在不同平台上行为完全不一样属于典型的可移植性坑。正确的写法是用换算宏msecs_to_jiffies(20)表示 20 毫秒usecs_to_jiffies(500)表示 500 微秒。这两个宏都是向上取整的也就是说msecs_to_jiffies(1)在HZ100的系统上会得到 1 jiffie也就是实际的 10 毫秒。所以别指望它做微秒级精确定时。想要更精确的延时只有两条路一是用hrtimer它是基于高精度时钟的可以做到微秒级二是用udelay/ndelay忙等但那只适合几十微秒以内的极短延时。delayed_work的定位从来不是精确定时器而是不太着急但要能睡觉的延后任务。再分享一个省电的小技巧如果你的模块要周期性地每隔一秒干点什么用schedule_delayed_work(dwork, round_jiffies_relative(HZ))代替HZ。round_jiffies_relative会把到期时间对齐到整秒的边界上让系统里所有类似的周期任务尽可能在同一个 tick 上被唤醒CPU 就能少醒几次对移动设备和平板的续航有肉眼可见的收益。4. 一个能直接编译加载的最小模块自续期周期任务加安全卸载理解了机制接下来上一份能跑的代码。这个模块做的事情很典型加载后每隔一段时间打印一次计数并且自己给自己续期形成一个周期任务卸载的时候要保证彻底停下来不能有任何残留的执行。这套骨架可以直接套用到传感器轮询、看门狗喂狗、状态机心跳等场景。4.1 完整模块代码/* my_dwq.c */ #include linux/module.h #include linux/kernel.h #include linux/init.h #include linux/slab.h #include linux/jiffies.h #include linux/workqueue.h #include linux/moduleparam.h static int interval_ms 1000; module_param(interval_ms, int, 0644); MODULE_PARM_DESC(interval_ms, 两次执行之间的延迟单位毫秒); struct my_ctx { struct delayed_work dwork; /* 必须内嵌不能是指针乱指 */ unsigned long count; bool running; /* 续期开关 */ }; static struct my_ctx *ctx; static void my_work_fn(struct work_struct *work) { struct delayed_work *dwork to_delayed_work(work); struct my_ctx *c container_of(dwork, struct my_ctx, dwork); c-count; pr_info(my_dwq: 第 %lu 次执行, jiffies%lu, on cpu %d\n, c-count, jiffies, smp_processor_id()); /* 如果这里要访问硬件可以放心睡眠 */ /* msleep(5); */ /* mutex_lock(some_lock); ... */ if (c-running) schedule_delayed_work(c-dwork, msecs_to_jiffies(interval_ms)); } static int __init my_dwq_init(void) { ctx kzalloc(sizeof(*ctx), GFP_KERNEL); if (!ctx) return -ENOMEM; INIT_DELAYED_WORK(ctx-dwork, my_work_fn); ctx-running true; /* 首次调度 */ schedule_delayed_work(ctx-dwork, msecs_to_jiffies(interval_ms)); pr_info(my_dwq: loaded, interval%d ms\n, interval_ms); return 0; } static void __exit my_dwq_exit(void) { if (!ctx) return; /* 关键顺序先把续期开关关掉再取消 */ ctx-running false; cancel_delayed_work_sync(ctx-dwork); kfree(ctx); ctx NULL; pr_info(my_dwq: unloaded\n); } module_init(my_dwq_init); module_exit(my_dwq_exit); MODULE_LICENSE(GPL); MODULE_AUTHOR(your name); MODULE_DESCRIPTION(schedule_delayed_work usage demo);代码里有三处值得单独解释。to_delayed_work(work)是个容器转换宏因为回调收到的是struct work_struct *而我们需要的是包着它的struct delayed_work。再用一次container_of才能拿到最外层的自定义结构体。这两个宏连用是标准姿势写多了就形成肌肉记忆了。如果你把delayed_work定义成结构体的第一个成员甚至可以直接强转但我不建议这么写可读性太差。c-running这个标志位不是为了好看。取消一个会自我续期的 work 是有讲究的如果只是在exit里调cancel_delayed_work_sync而此时回调正在另一个 CPU 上执行并且恰好走到了续期那一行那么取消这个动作可能先完成然后 work 又被重新排进队列——模块卸载后回调还会被调用这时候你的结构体已经被kfree了直接就是一个 use-after-free。先关标志位再取消能保证即使出现这个竞争回调看到标志位为假就不会续期而cancel_delayed_work_sync会把当前这次执行等完。kfree放在cancel_delayed_work_sync之后是硬性要求。这一步保证了回调彻底不会再执行和内存被释放之间有明确的先后关系。4.2 编译、加载与验证模块的 Makefile 就四行注意KDIR指向当前内核的构建目录如果是交叉编译到 ARM 板子上把它改成目标平台的内核源码路径即可obj-m : my_dwq.o KDIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean编译和验证的完整流程make sudo insmod my_dwq.ko interval_ms2000 dmesg -w # 另开一个终端看输出CtrlC 退出 lsmod | grep my_dwq sudo rmmod my_dwq dmesg | tail -20dmesg -w会把日志实时刷出来注意观察输出的时间间隔。你会看到第一次执行的时刻是加载时间 2 秒之后每次的间隔大致是 2 秒但是会有一点点抖动。用date %s.%N配合日志时间戳对比一下抖动在几十毫秒以内都属于正常范围主要来自调度延迟和 tick 的粒度。如果想验证以续期方式跑和用固定周期的区别可以把interval_ms设成 200然后观察由于每次续期都是从当前时刻开始算实际周期会是执行耗时 200ms误差会慢慢累积。这在需要长期保持平均周期准确的场景比如每秒采样一次做积分计算是不能接受的。解决办法是记录一个基准时刻每次都用next base n * period的方式重新计算延迟把累积误差吃掉。4.3 换成防抖场景mod_delayed_work 的正确用法按键消抖是delayed_work最经典的应用。思路是检测到电平变化就重新计时如果在 20ms 内又变了就把计时往后推直到电平稳定 20ms 才真正上报事件。用cancel_delayed_workschedule_delayed_work组合去实现会有一个经典的丢事件 bug如果取消的时候那个 work 已经开始执行了cancel_delayed_work返回 false 但拦不住它而这时你又排了一个新的结果就是同一个按键事件被上报了两次。用mod_delayed_work就没这个问题static void key_irq_handler(int irq, void *dev_id) { struct key_ctx *k dev_id; /* 一次原子操作完成如果已排队就改期没排队就新入队 */ mod_delayed_work(system_wq, k-debounce, msecs_to_jiffies(20)); } static void key_debounce_fn(struct work_struct *work) { struct key_ctx *k container_of(to_delayed_work(work), struct key_ctx, debounce); /* 这里读 GPIO可以走 I2C 扩展芯片可以睡眠 */ k-stable_level gpio_get_value(k-gpio); input_report_key(k-input, k-keycode, !k-stable_level); input_sync(k-input); }mod_delayed_work的语义是如果这个 work 已经在队列或定时器里就原子地把它改到新的到期时间如果没有就新排一个。全程只需要一次加锁中间不存在取消成功但排队失败或者取消失败但又排了一次的窗口。处理高频抖动的场景这个接口比cancel加schedule的组合靠谱得多。5. 我在实际项目里踩过的坑前面讲的是书上的道理这一节讲的是我在项目里真金白银换来的经验。这几条基本都是看文档看不出、只有炸过一次才记得住的类型。5.1 卸载模块时崩溃exit 里忘了 cancel最早写驱动的时候我在module_exit里只是把kfree一写就完事了觉得 work 会自己跑完。结果是rmmod之后随机出现Unable to handle kernel paging request堆栈里能看到kworker在调用我已经释放掉的函数指针。原因就是模块卸载流程根本不关心你还有没有 pending 的 work。rmmod会卸载整个模块的内存映射但你排进system_wq的 work 还躺在队列里。等它到期内核去执行一个已经不存在于地址空间的函数崩溃是必然的。所以规矩只有一条只要模块里用了工作队列module_exit里必须有一句cancel_delayed_work_sync或者是flush_workqueuedestroy_workqueue的组合一个都不能漏。如果你的模块里有多个 work那就每个都要取消或者干脆把它们都挂在自建队列上用destroy_workqueue一次性收干净——后者更不容易漏。5.2 自续期 work 的幽灵重启与竞争窗口自续期模式看着优雅实际上藏着一个很隐蔽的竞争cancel_delayed_work_sync保证的是取消时已经在执行的那一次会跑完但如果那次执行过程中又调用了一次schedule_delayed_work这个新排进去的 work 有可能在取消操作完成之后才被处理。内核文档里对这一点也有提示大意就是如果一个 work 会给自己重新入队那么cancel_*_sync返回时并不能 100% 保证它以后再也不会执行。所以标准解法就是在回调里加一个续期开关由取消方先关掉它再调cancel。这就是第 4 章代码里running字段存在的意义。这个模式我在至少三个项目里复用从来没出过问题。注意running这个标志位在有多线程并发访问时理论上应该用WRITE_ONCE/READ_ONCE或者atomic_t。严格来说这里存在一个可见性窗口实际调测试很难触发但做代码审查的时候会被提出来养成习惯比较好。5.3 在 work 回调里取消自己导致卡死有一次我在回调里判断如果重试次数超过上限就停止这个任务于是顺手写了一行cancel_delayed_work_sync(dwork)。结果是加载之后第一次触发就卡死了机器桌面直接僵住dmesg里能看到INFO: task kworker/0:2 blocked for more than 120 seconds。原因很直白cancel_delayed_work_sync的语义是等到这个 work 不在执行中才返回而你现在就是这个 work 的执行体。等自己结束那就是死锁。正确的写法是在回调里只更新状态、关掉续期开关不做取消真正调用取消的地方留给外部的关闭流程。类似的陷阱还有在持有自旋锁的时候调用cancel_delayed_work_sync或者在有原子上下文的地方调用flush_workqueue。记一条简单规则就够了凡是名字里带sync、或者带flush的函数都要问一句我现在允许睡眠吗。5.4 共享 system_wq 上跑了长任务被堵住有一版固件里我在system_wq上排了一个会读取几十 KB 数据的任务中间走了 SPI 同步传输一次要几百毫秒。上线之后用户反馈偶尔感觉操作卡顿查下来发现同一时刻其他模块的 work 都被延后了。原因是system_wq是共享资源虽然是 per-CPU 并且支持多并发但并发度有上限而且同一个 CPU 上的 worker 数量是有限的。一个几百毫秒的任务占住一个 worker其他排在同一 CPU 上的短任务可能就得等。这就跟高峰期只有几个窗口的银行柜台一样前面有人办大业务后面全等着。修复方式很简单给这个长任务单独alloc_workqueue一个队列加上WQ_UNBOUND让它能跨 CPU 调度这样它再怎么耗时也不会影响system_wq上的其他用户。这也是我在项目里定的一条规矩凡是可能超过几十毫秒的 work一律走自建队列。5.5 关于睡眠边界的三条对照把什么地方能做什么整理成一张对照表代码审查的时候直接照着核对能过滤掉大部分低级错误。场景能否调用schedule_delayed_work能否调用cancel_delayed_work_sync中断上半部 / 软中断可以不可以会睡眠tasklet 回调可以不可以持有自旋锁的临界区可以不可以进程上下文含 work 回调可以可以但不能取消自己module_exit可以可以必须调对于上层应用开发者来说这张表也解释了为什么程序对同一个 sysfs 节点做write之后读回来的状态有时候是还没生效——驱动很可能就是在write路径里排了一个delayed_work返回值立刻给了你实际状态要等 kworker 跑完才更新。这种异步生效的设计在传感器校准、射频参数切换这类场景里非常常见调试的时候要留出足够的等待时间再断言。6. 出问题了去哪看从 kworker 线程一路查到 ftrace工作队列出问题的表现往往很模糊任务没跑、跑晚了、跑了两遍、或者干脆整个系统卡住。下面是我自己排查时常用的几个抓手按从外到内的顺序介绍。6.1 先确认 kworker 是不是在干活最外层的一步用ps看 worker 线程的状态。名字形如kworker/0:2的内核线程斜杠后面跟的第一个数字是绑定的 CPU 编号冒号后面是同一个 CPU 内的编号。ps -eLo pid,tid,psr,stat,comm | grep kworker cat /proc/pid/stack # 看某个 kworker 当前卡在哪个内核函数里如果某个 kworker 的stat长期是D不可中断睡眠而且stack显示它卡在某个驱动的函数里那基本就能定位到是哪个 work 把 worker 占住了。另外要注意内核线程是按需创建的如果某个 CPU 上一个kworker都没有那通常说明队列是空的而不是出问题了。6.2 用 ftrace 追踪 work 的排队与执行ftrace 里有一组专门给工作队列准备的事件能看到每一个 work 从排队到执行完成的完整时间线。把下面这段贴进脚本能看到非常直观的结果cd /sys/kernel/debug/tracing echo 0 tracing_on echo trace echo workqueue:* set_event # 只关心我们自己的回调 echo function my_work_fn \ events/workqueue/workqueue_execute_start/filter echo 1 tracing_on sleep 5 echo 0 tracing_on cat trace | head -50输出的每一行会带上时间戳、CPU 编号、work 结构体的地址和函数名。把workqueue_queue_work的时间戳和workqueue_execute_start的时间戳一减得到的就是排队到执行的延迟把execute_start和execute_end相减得到的是回调本身的耗时。这两个数据一经对比就能立刻区分是调度晚了还是是自己跑太久了省掉大量猜测时间。需要提醒的是set_event里的workqueue:*会抓到系统里所有模块的 work 事件在高负载机器上量非常大可能会把 trace buffer 冲爆。所以一定要加 filter 或者设置 buffer 大小。我在生产环境上抓过一次忘了加 filter五秒钟就冲掉了几个 G 的 buffer。6.3 workqueue lockup 告警与 debugfs 目录如果某个 work 卡住超过几十秒而且内核打开了CONFIG_WQ_WATCHDOG配置内核会主动打印一条 lockup 风格的告警里面会指明是哪个 worker pool、卡了多久。看到这个告警的第一件事是去dmesg里找这个 pool 上最后执行的是哪个 work。比较新的内核5.9 以后在/sys/kernel/debug/workqueue/下提供了运行时状态输出可以直接看到每个队列的 worker pool 状态、当前活跃的 work 数量、以及池子的饱和度ls /sys/kernel/debug/workqueue/ cat /sys/kernel/debug/workqueue/summary老内核上这个目录可能不存在那就退回到/proc/下找相关信息或者直接用 ftrace 观察。工具会变但先看 worker 状态、再看时间线、最后看回调耗时这个排查顺序是一直有效的。6.4 常见现象与原因的对照表现象最可能的原因处理方向任务只执行了一次就没了回调里忘了写续期那行或者续期开关被误关检查回调末尾和running字段重复调度后任务被延后了用了cancelschedule组合撞上取消窗口换成mod_delayed_workrmmod之后立刻内核报错exit里没取消 work补cancel_delayed_work_sync回调里调_sync函数后系统卡死在 work 里取消自己或持锁调用把取消动作挪到外部流程相同周期任务的时间越跑越偏每次续期都从当前时刻算误差累积用基准时刻加序号的算法重算延迟一个 work 阻塞导致其他 work 都不跑共享队列的 worker 被长任务占满长任务迁到自建WQ_UNBOUND队列期望毫秒级精度但抖动很大delayed_work本身不是精确定时器改用hrtimer这张表里的每一条我都在真实项目里对应过一次其中重复调度后任务被延后那条最坑因为现象是间歇性的只有按键按得特别快的时候才复现。当时排查了两天才定位到取消和重排之间的窗口换成mod_delayed_work之后问题彻底消失。最后再分享一个写代码时的小习惯凡是定义了delayed_work的地方在结构体旁边用注释标明由谁负责取消。因为这个东西的责任归属一旦模糊就很容易出现两个模块都以为对方会取消、结果谁都没取消的情况最后的表现就是卸载模块时随机崩溃而且换台机器可能就不复现了非常难查。
返回列表