1. 从一次内核调试说起:timer wheel 到底是个啥
先别急着翻源码,我先问你一个问题:你写 Linux 驱动的时候,有没有想过mod_timer那个定时器,内核底层到底是怎么组织和触发的?我早年间第一次认真看kernel/time/timer.c的时候,第一反应是“这代码怎么长得跟桶排似的”,后来才反应过来,这就是经典的timer wheel(时间轮)算法。
timer wheel 定时器是 Linux 内核里最基础、最常用的一套低精度定时器机制,它负责管理大量“过一会儿再干活”的任务——比如网卡的超时重传、块设备的 IO 超时、驱动里的轮询任务、协议栈里的各种延时处理。你写的每一个add_timer、mod_timer、del_timer_sync,背后都是它在运作。它的精度是 jiffies 级别,默认 HZ=1000 的时候就是 1ms 粒度;HZ=250 的时候就是 4ms 粒度。
这套机制适用的场景是“量大、单个精度要求不高、不能占用太多 CPU”。它不会像高精度定时器 hrtimer 那样去操作硬件时钟,而是在每次时钟中断(tick)发生后,批量检查有没有到期的定时器,到期了就执行回调函数。你琢磨一下:一台服务器上可能有几千上万个定时器在同时跑,如果每次都把全部定时器遍历一遍,那 CPU 早就被干废了。timer wheel 的核心价值就是用极小的开销管理海量定时器。
这篇文章我打算把 timer wheel 的整个设计思路、数据结构、添加/删除/触发流程,以及我在实际驱动开发里调试定时器时踩过的坑全部扒一遍。不管你是做嵌入式内核移植、驱动开发,还是纯粹啃内核源码,这篇文章都能让你少走不少弯路。
2. timer wheel 的整体设计与数据结构拆解
2.1 为什么需要“轮子”:从链表到分级数组的演进
最原始的定时器实现其实就是一条有序链表,按到期时间排好序,每次 tick 检查链表头。但问题来了:插入一个定时器,最坏情况要遍历整条链表,复杂度 O(n);删除也一样。当系统里的定时器数量从几十涨到几千,这种实现直接跪。
更狠的需求是:内核里随时会新增定时器、修改定时器超时时间、取消定时器,而且这些操作发生在各种上下文里(进程上下文、中断上下文、软中断上下文)。所以内核需要一种“插入快、删除快、触发快”的数据结构。树形结构可以做到 O(log n) 的插入,但实现复杂、cache 不友好;而 timer wheel 用“空间换时间”的思路,把未来的时间轴切成很多个“槽位”,每个槽位挂一个链表,到期时间相同的定时器都挂到同一个槽位里。这样插入的时候直接哈希到槽位,O(1);触发的时候处理当前槽位的链表,也是 O(1)。
这就是 timer wheel 的核心思想:提前按到期时间分类排队,时间到了直接处理对应队列。有点像你去银行取号,每个号码段对应一个窗口,而不是所有人在一条队伍里硬排。
2.2 五级时间轮:tv1 到 tv5 的完整结构
Linux 的 timer wheel 并不只有一个轮子,而是五个轮子串在一起。源码里能看到一个很醒目的结构体struct timer_base,它里面定义了:
struct timer_base { raw_spinlock_t lock; struct timer_list *running_timer; unsigned long clk; unsigned long next_expiry; unsigned int cpu; bool is_idle; bool must_forward_clk; struct hlist_head tv1[256]; // 第一级:256个槽 struct hlist_head tv2[64]; // 第二级:64个槽 struct hlist_head tv3[64]; // 第三级:64个槽 struct hlist_head tv4[64]; // 第四级:64个槽 struct hlist_head tv5[64]; // 第五级:64个槽 };每级都是一个hlist_head数组。为什么 TV1 是 256 个槽,后面几级都是 64 个槽?这跟二进制的移位、mask 运算有关,用位运算可以直接算出槽位索引,特别快(后文详细展开)。
每一级数组里的一个“槽位”代表一个固定的时间跨度:
- TV1 每个槽代表 1 个 jiffy,所以 TV1 一共覆盖 256 个 jiffy。
- TV2 每个槽代表 256 个 jiffy,一共 64 个槽,覆盖 16384 个 jiffy。
- TV3 每个槽代表 16384 个 jiffy,覆盖 1048576 个 jiffy。
- TV4 每个槽代表 1048576 个 jiffy,覆盖 67108864 个 jiffy。
- TV5 每个槽代表 67108864 个 jiffy,也就是说处理超长延时(相对 jiffies 来说)的任务。
在实际计算里,内核会根据 expires 与当前 clk 的差值,决定把这个定时器放到哪一级的哪个槽位里。这种“差值越大、放到越高级的轮子”的设计,本质上和“秒针、分针、时针”差不多:秒针转一圈,分针走一格;分针转一圈,时针走一格。电视机的画面大家都有印象,秒针咔哒转一圈,分针轻轻跳一格,这就是级联的思想。
2.3 索引计算:位运算替代除法和取余
在 timer wheel 里,把一个 expires 转成“第几级、第几个槽”不是用expires / 256 % 64这种除法和取余来算的,而是直接用移位和位与。
我们得先理解一个前提:内核里 jiffies 一直在增加,而 timer wheel 关心的是“距离当前时刻还有多少个 jiffy”。假设当前 clk 是某个值,如果你拿expires - jiffies来表示“还剩多久”,那么:
- 差值小于 256,说明可以放到 TV1 的某个槽。具体槽位就是
expires & 255,因为 TV1 只有 256 个槽,按模 256 取索引即可。 - 差值在 256 到 16383 之间,放入 TV2。槽位索引用
(expires >> 8) & 63。 - 差值在 16384 到 1048575 之间,放入 TV3。索引用
(expires >> 14) & 63。 - 差值在 1048576 到 67108863 之间,放入 TV4。索引用
(expires >> 20) & 63。 - 更大的放到 TV5。索引用
(expires >> 26) & 63。
这套“移位 + 与操作”为什么快?因为 CPU 做一次移位和与操作通常只需要一个时钟周期,而除法需要几十个周期。内核这种追求极致的优化思路,在 timer wheel 的索引计算上体现得淋漓尽致。
你可能会有疑问:为什么不安排成每一级都 256 个槽?其实可以,但内存会变大。现在这种 256/64/64 的组合,整个 timer wheel 也就:(256 + 644) * sizeof(struct hlist_head),一个hlist_head就是两个指针的大小,整个结构只有大概几百字节。如果全部用 256 槽,多不了多少,但分级轮询和“低层优先触发”的机制就需要更复杂的判断。所以 256 + 644 是在内存、时间复杂度、设计复杂度之间折中的结果。
3. 核心机制拆解:从添加定时器到触发回调
3.1 添加定时器:expires 如何变成槽位索引
你写驱动时一般会这样初始化一个定时器:
struct timer_list timer; timer_setup(&timer, my_timer_callback, 0); timer.expires = jiffies + msecs_to_jiffies(100); add_timer(&timer);这里的关键是add_timer。它的内部调用链是:add_timer→__mod_timer→internal_add_timer。internal_add_timer会根据 expires 和 base->clk 的距离决定把定时器挂到哪一级的哪个槽。
从内核源码里可以看到,internal_add_timer的逻辑大概是这样的:
static inline void internal_add_timer(struct timer_base *base, struct timer_list *timer) { unsigned long idx = timer->expires - base->clk; struct hlist_head *vec; if (idx < TVR_SIZE) { // TVR_SIZE=256 int i = expires & TVR_MASK; // 取低8位 vec = base->tv1 + i; } else if (idx < 256 * 64) { int i = (expires >> 8) & TVN_MASK; // TVN_MASK=63 vec = base->tv2 + i; } else if (idx < 256 * 64 * 64) { int i = (expires >> 14) & TVN_MASK; vec = base->tv3 + i; } else if (idx < 256 * 64 * 64 * 64) { int i = (expires >> 20) & TVN_MASK; vec = base->tv4 + i; } else { int i = (expires >> 26) & TVN_MASK; vec = base->tv5 + i; } hlist_add_head(&timer->entry, vec); }代码里的expires & TVR_MASK就是取 expires 的低 8 位,正好落在 0~255 之间。为什么可以这么粗暴地取索引?因为 TV1 覆盖 256 个 jiffy,时钟每走一个 jiffy,就会顺序往后移动一个槽位;下次到达这个槽位对应的 jiffy 时,这个 slot 里的定时器就该触发了。而 TV2 里的一个槽位代表 256 个 jiffy,也就是说 256 个 jiffy 范围内,所有被散列到 TV2 同一个槽位的定时器,会在那一个时刻被“拉出来重新分发”。
细心的朋友应该发现了:TV1 覆盖的 0~255 是直接按 jiffies 对齐的,但 TV2 的 256 个大槽是按 256 对齐的。所以 expires 在 [clk+256, clk+511] 这个区间里的定时器,和 [clk+512, clk+767] 区间里的定时器,虽然都挂在 TV2 上,但槽位不同。代码里用(expires >> 8) & 63,是因为要对齐到 256 的整数倍,把高位的值映射到 TV2 的槽位。
3.2 修改与删除:mod_timer 和 del_timer 的细节
实际项目里用add_timer的场合很少,绝大多数都是用mod_timer。因为拿到一个结构体,你很难确定它之前到底有没有被加进 timer wheel;而mod_timer既有“修改超时时间”的功能,也有“若不在队列中则插入”的效果,所以它是更安全的入口。
mod_timer的关键点在于:必须先把旧的定时器从原来的链表摘下来,再按照新的 expires 重新计算槽位。如果不摘,就会导致同一个 timer_list 挂在两个链表上,触发时就会出现重复执行回调,甚至操作已释放内存的竞态。内核里对应的是detach_timer:
static int detach_timer(struct timer_list *timer, bool clear_pending) { struct hlist_node *entry = &timer->entry; if (!hlist_unhashed(entry)) { hlist_del_init(entry); return 1; } return 0; }hlist_unhashed检查节点的pprev是否为 NULL。在 hlist 里,链表头节点的pprev指向链表头的first字段,而链表中间节点的pprev指向前一个节点的next字段。只要这个节点不在链表中,pprev就是 NULL。所以用hlist_unhashed判断节点是否在链表上,是内核里很常用的小技巧。
del_timer和del_timer_sync有什么区别?del_timer只是把定时器从链表摘除,但如果定时器回调正在另一个 CPU 上执行,del_timer返回后,那个回调可能还没跑完。而del_timer_sync会等待回调函数执行完才返回,但代价是它不能用在中断上下文,否则会出现死循环自旋等待。你在中断处理函数里想取消一个定时器,绝对不能调用del_timer_sync,这是驱动开发中的经典死锁场景。我一直的习惯是:进程上下文用del_timer_sync,中断上下文只敢用del_timer加timer_pending判断。
3.3 触发流程与级联 cascade 机制
定时器是怎么被触发的?每次 tick 产生时,内核会调用run_timers。它的执行逻辑大致是:
void run_timers(void) { struct timer_base *base = this_cpu_ptr(&timer_bases[BASE_STD]); if (!base->pending_map) return; /* 找到当前 clk 对应的索引 */ index = base->clk & TVR_MASK; hlist = base->tv1 + index; __run_timers(base, hlist); }注意base->clk表示当前已处理到的 jiffies 值。TV1 的当前槽位就是clk & 255,这个槽里挂着的所有定时器都恰好在这个 jiffy 到期,直接遍历链表执行回调就行。
真正麻烦的是级联:当 TV1 指针转了一圈(处理完 256 个槽位),就需要从 TV2 拉一个槽位的定时器出来,把它们重新按照各自的 expires 分发到 TV1。同理,TV2 转完一圈,要从 TV3 拉一个槽位下来,以此类推。
这个操作在源码里的cascade函数里,代码逻辑其实很简单:
static int cascade(struct timer_base *base, struct timer_list *timer, unsigned int level) { struct hlist_head *hlist; int i; hlist = base->tv[level] + (timer->expires >> level * 8) & TVN_MASK; for (i = 0; i < 64; i++) { struct timer_list *tmp; hlist_move_list(hlist, &tmp); while (tmp) { struct timer_list *t = tmp; tmp = tmp->entry.next; internal_add_timer(base, t); } } return 0; }你可以把 cascade 理解为“从高级别轮子里取出一格,重新映射到低级别轮子的多个格里”。这个操作不是每个 tick 都做,而是 TV1 每转 256 格才做一次,TV2 每转 64 大格才做一次,总共的运行开销均摊下来是非常低的。
还有一个容易让人忽略的优化:当 TV1 索引到某一个槽位时,并不是每次都直接遍历链表,而是先检查一个pending_map(位图)。这个位图记录每个槽位上是否有定时器,耗时操作变成了位图查找。如果某个槽位对应的位是 0,直接跳过。
3.4 pending_map 与 next_expiry:快速定位下一个到期定时器
新版本的内核(4.15 以后)在 timer_base 里引入了pending_map和next_expiry。pending_map是一个按级存储的二进制位图,比如 TV1 的位图是 256 bit,TV2 到 TV5 各 64 bit。有了位图,内核调用调度器 tick 时,可以非常快速地判断当前 CPU 上“最近的一个到期定时器在哪个槽位”。
具体实现里,查找下一个到期定时器用的是find_next_bit。这个函数在 arm64 上会翻译为 CLZ/CTZ 指令,一次就能找到第一个置位的 bit。找完 TV1 没找到,再往 TV2 的位图找。整个过程的时间复杂度接近 O(1)。这也是为什么新内核在高负载下,定时器子系统的开销这么低的原因之一——绝大多数 tick 进来,都只是做几次位运算,发现没有到期定时器,就直接返回了,连链表都不用动。
4. timer wheel 在定时器生态里的定位与对比
4.1 与 MCU 定时器(stm32、51)的设计思路差异
很多做嵌入式的朋友是从 51 单片机、STM32 的硬件定时器开始接触“定时器”这个概念的。在 MCU 上,定时器本质是一个硬件计数器:给内部时钟分频后,计数器向上数,数到预装载值就产生中断。你设置PSC预分频、ARR自动重装载、CCR比较值,本质上是在“配置硬件”。
这种模式的好处是:几乎不占用 CPU,精密、可靠,适合对时序要求严格的场景。缺点是:一个 MCU 里的硬件定时器数量有限(比如 STM32F103 也就是几个 TIM),你要同时做 10 路 PWM 输出、5 路输入捕获、3 个超时计时,硬件定时器根本不够用。所以 MCU 上通常会有一个“软件定时器”模块,把多个软件定时器挂在同一个硬件 tick 上,用链表或数组管理。STM32 的 HAL 库HAL_TIM_Base_Start_IT配合HAL_TIM_PeriodElapsedCallback实现多路软定时,本质上就是定时器分时复用。
而 Linux 内核的 timer wheel 是纯软件实现,它依赖硬件只提供一个周期性的 tick(比如 ARM 的 Generic Timer,或者 x86 的 APIC Timer),剩下的海量定时器全部用软件组织起来。所以它能在单个 CPU 上支撑成千上万个软件定时器,代价就是精度被限制在 tick 级别。如果你用 STM32 的 HAL 库做过那种“多路软件定时器同时跑”的项目,你会发现核心思路和 timer wheel 有异曲同工之妙:定时器结构体里存超时时间,主循环每次中断判断哪些到期了,到期就执行回调。
对比下来,结论很明确:如果你要的是硬实时、精确到微秒甚至纳秒、固定频率的 PWM,硬件定时器不可替代;如果你要的是大量可创建、可销毁、可动态调整的“事件调度”,timer wheel 这种软件定时器才是正解。
4.2 与 hrtimer 的边界:高精度定时器为什么不是万能的
Linux 内核里除了 timer wheel,还有一套叫 hrtimer 的高精度定时器。它的实现不再用 jiffies 刻度,而是基于 clocksource 直接比较绝对时间(ktime_t),并通过红黑树按过期时间排序。精度可以达到纳秒级别,而且不一定依赖 tick 中断。
那为什么内核不干脆全面使用 hrtimer?原因有两个。
第一,hrtimer 的红黑树每个节点需要额外的指针空间(left/right/parent/color),天然比 hlist 节点大;红黑树的插入和删除是 O(log n),在定时器数量极大的场景下,这个开销会被放大。而且红黑树的缓存局部性差,节点在内存里可能分散得很远。
第二,hrtimer 的触发需要一个机制,要么逐个设置硬件到期中断,要么在 tick 到来时顺带检查。Linux 最终在动态 tick(tickless)模式下,hrtimer 是可以直接编程硬件时钟的,所以nanosleep、posix_timer走的是 hrtimer 而不是 timer wheel。
那 timer wheel 适合什么?答案是周期性、大量、延迟允许 1ms 左右误差的任务。比如网络协议栈的 TCP 重传定时器、ARP 超时、文件系统的延迟写入。如果你用 hrtimer 跑几千路超时,照样会很吃力。所以内核里的分工是:timer wheel 管量大管饱,hrtimer 管精细准时,两者各司其职。
4.3 与用户态 cron 定时器、C 定时器对比
顺带提一下用户态的 cron 和常见的 C 定时器。cron 的力度是“分钟级”,它只用一张 crontab 表,每分钟扫一遍;而 C 语言里常见的setitimer、timerfd,实际上底层都会进入内核,最后落到 hrtimer 或 timer wheel 上。你通过timerfd读事件时,其实读到的就是一个“到期通知”,完全由内核帮你管理。
我在做项目时有一个经验:在应用层做大量短周期定时器,不如直接用timerfd + epoll,让内核帮忙管理,CPU 占用率比你自己维护一个优先队列要低得多。这个思路本质上就是把“时间管理”下沉到内核的 timer wheel/hrtimer 机制里,而不是自己造轮子。
5. 实操阶段:如何在驱动里正确使用 timer_list
5.1 驱动开发中的标准用法模板
在内核驱动里使用 timer_list,我总结了一套比较稳妥的模板,直接照抄都行:
#include <linux/timer.h> #include <linux/jiffies.h> struct my_device { struct timer_list timer; struct my_priv_data *data; // ... 其它字段 }; static void my_timer_callback(struct timer_list *t) { struct my_device *dev = from_timer(dev, t, timer); // 注意:这里运行在软中断上下文,不能睡眠,不能调 kmalloc(GFP_KERNEL) // 如果要做复杂处理,用 schedule_work() 丢到 workqueue mod_timer(&dev->timer, jiffies + msecs_to_jiffies(100)); // 周期定时器 } static int my_driver_probe(struct platform_device *pdev) { struct my_device *dev; dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL); timer_setup(&dev->timer, my_timer_callback, 0); dev->timer.expires = jiffies + msecs_to_jiffies(100); add_timer(&dev->timer); } static void my_driver_remove(struct platform_device *pdev) { struct my_device *dev = platform_get_drvdata(pdev); del_timer_sync(&dev->timer); // 确保移除时回调不再执行 }这里有两个非常容易踩的坑:
第一个坑:timer_list回调执行时,设备可能已经被释放了。比如你del_timer_sync了,但回调在其他 CPU 上执行到了from_timer之后,这时你把dev释放了,回调继续访问dev->data就野指针了。所以释放设备和删除定时器的顺序要小心。通常我会在回调函数开头加一个对dev有效性的判断,或者用一个 flag 标记“设备已经停止”。
第二个坑:回调里不能直接调del_timer_sync(&dev->timer)等自己。因为回调是在定时器软中断里执行的,如果回调中再等同一个定时器结束,会形成自死锁。如果你需要在回调中停止定时器,直接用del_timer就好,或者干脆让条件判断不重装定时器。
5.2 调试三板斧:/proc/timer_list、trace 事件、slab 信息
我自己调试定时器相关问题,最常用的是这三招。
第一招:直接看/proc/timer_list。这个文件会把当前系统的所有活动定时器、每个 CPU 的 timer_base 信息、过期时间等打出来,能看到哪个进程/驱动挂了大量定时器。在内核里,timer_start追踪点会记录定时器的起点,配合bpf或perf查看更精确。
第二招:使用 ftrace 的 timer 事件。内核在kernel/time/timer.c里定义了一组 tracepoint,包括timer_start、timer_expire_entry、timer_expire_exit、timer_cancel等。你可以在终端里这样用:
cd /sys/kernel/debug/tracing echo 'timer:*' > set_event echo 1 > tracing_on cat trace_pipe这样能抓到每次定时器从注册到到期执行回调的完整时序,特别适合排查“我的定时器怎么没触发”和“我的定时器怎么触发了两次”这类问题。
第三招:如果怀疑定时器相关内存泄漏,看/proc/slabinfo里timer_list和callback_head两个 slab 的高速缓存增长情况。在驱动加载前后对比一次,如果定时器一直增长不释放,多半是add_timer了但没del_timer。
5.3 定时器回调里的最佳实践:短平快
timer wheel 的回调是在软中断上下文执行(具体来说是TIMER_SOFTIRQ),执行期间当前 CPU 的软中断处理会被占用。如果你在回调里做了耗时很长的任务,会导致同一 CPU 上的其他软中断延迟变大,网络包处理、块设备处理都会跟着卡顿。
我的规则是:回调函数 10 微秒内必须返回。如果任务比较复杂,把耗时操作放入 workqueue(schedule_work)或 kthread,定时器回调里只负责“通知一声”。
举个例子,之前做一个网卡驱动的统计上报,每秒钟要把几十个计数器打成一条日志。如果用printk直接在定时器回调里输出,网络吞吐会掉一半。后来改成在回调里schedule_work,把打日志的任务放到线程上下文,吞吐立刻恢复。这个经验很多人不听,非要等到线上出问题才追悔莫及。
6. timer wheel 的演进与新特性:看见算法背后的工程智慧
6.1 从 per-lock 到 per-cpu:每个 CPU 一个 base
现代内核里timer_base是 per-CPU 的,也就是说每个 CPU 有自己的时间轮。这样设计的好处很明显:每个 CPU 只操作自己本地的定时器链表,不需要跨 CPU 锁,大大降低了锁竞争。
这带来一个隐含的编程约束:你在 CPU0 上add_timer的定时器,可能最终挂在 CPU0 的 timer_base 上,由 CPU0 的 tick 触发。如果你在 CPU1 上删这个定时器,就需要加锁访问 CPU0 的 base。内核里add_timer系列接口会自动把定时器挂到当前 CPU 的 base(当然也有add_timer_on这种强制指定 CPU 的接口)。
所以在 SMP 系统上,del_timer_sync 的实现也会考虑“目标定时器可能在别的 CPU 的 base 上”,等对应 CPU 的软中断跑完了才算真正结束。这一点在实时调度场景下比较重要:如果一个定时器回调被放到了某个隔离 CPU 上,但那个 CPU 因为某种原因卡死了,del_timer_sync可能长时间等待。
6.2 NO_HZ 对 timer wheel 的影响:动态 tick 下的冻结与转发
在 NO_HZ_IDLE 模式下,CPU 进入 idle 后,如果没有任何到期事件,tick 可以停止。但一旦 timer wheel 上挂着一个未来要触发的定时器,内核就需要保证在它到期之前醒来。这个任务由timer_base里的next_expiry字段负责。它在每次增删定时器时都会更新,idle 进程据此设置下一个唤醒时间。
这就引出另一个经典 bug:CPU 长时间 idle 后醒来,base->clk和实际 jiffies 差了一大截。如果此时有定时器需要触发,而 expires 远小于当前 jiffies,说明这个定时器在睡眠期间就“过期了”,必须立即处理。内核里forward_timer_base会做时间补偿,把base->clk快速推进到当前值,避免在旧槽位上死转。
如果你在做低功耗嵌入式 Linux(比如用 CONFIG_NO_HZ_IDLE 的场景),一定要理解这个机制:定时器回调的触发时间可能不能精确到你设置的那个点,因为在 CPU 睡眠期间,tick 是停掉的,只能下次唤醒后再补执行。如果你的业务要求“定时器必须严格准点”,那就要把 CPU 唤醒或者使用 hrtimer。
6.3 新内核里还有什么变化
最近几个版本的内核(6.x)对 timer wheel 也做了一些微调,核心思路是减少锁粒度、减少位图查找的次数、优化级联路径。比如引入了 lockless check(无锁快速路径检查 pending_map),让__run_timers能极快地跳过空槽。
我还看到有人尝试用跳表替代多级时间轮,但在主线内核里没有合并,原因就是时间轮的 O(1) 均摊成本太漂亮了,而且代码足够简单可靠。硬件设备、网络协议栈都深度依赖它的稳定行为,贸然换数据结构风险太大。
7. 常见问题排查速查表
我在不同项目里反复遇到定时器相关的问题,整理成一张表,方便你对照排查:
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 定时器回调没执行 | 定时器被del_timer但代码里没注意;或者 expires 已经过去,但 NO_HZ idle 延迟了 tick | 用 ftrace timer 事件确认是否注册;检查del_timer_sync是否被提前调用 |
| 定时器回调执行了多次 | 同一个timer_list被add_timer/mod_timer多次但没先 detach;或者回调里再次mod_timer没控制好条件 | 检查trigger逻辑,pop 前timer_pending;不要在未到期时重复 add |
| 系统卡死,CPU 软中断占用过高 | 定时器回调里做了太耗时操作,或者回调里调用了del_timer_sync形成自死锁 | 把重活迁移到 workqueue;回调里禁止自我del_timer_sync |
| 定时器精度比预期差 | HZ 设置太低(如 HZ=100),或系统处于 NO_HZ_IDLE | 按需求配置 HZ=250/1000;用 hrtimer 替代对精度要求高的部分 |
| 驱动卸载崩溃 | 释放设备内存后,定时器回调仍在执行 | 使用del_timer_sync并确保在释放前完成;回调内用 flag 标记停止 |
mod_timer在中断上下文大量调用 | 对 timer_base 的锁竞争,特别是多 CPU 修改同一定时器 | 尽量避免高频 mod_timer;必要时拆成 per-cpu 定时器 |
排查定时器问题时最重要的心态是:先确认定时器有没有进队列、有没有被重新挂载,再谈为什么不触发、为什么触发晚。用 tracepoint 看一看,往往一眼就能定位。
8. 写在最后的一点心得
timer wheel 是我个人非常喜欢的一个内核算法案例,它不复杂,但设计得非常精巧。几十行代码把海量定时器管理得明明白白,你很难找到另一种方式,能在插入、删除、触发三个维度上同时做到这么高效的。它背后“分级 + 延迟处理 + 位图加速”的思路,不光在内核里有用,你做网络协议栈、做游戏服务器、做嵌入式调度器,都能用到同样的思想。
我个人在实际操作中体会最深的一点是:用 timer wheel 之前一定要想清楚精度和上下文的边界。在驱动里,什么时候该用 timer wheel,什么时候该换 hrtimer,什么时候该把逻辑丢给 workqueue,这些决策比背源码细节重要得多。源码可以查文档、查内核树,但方案设计的“手感”只能靠踩坑积累。
最后送你一个在实际项目里特别好用的小技巧:如果你需要在一个驱动里维护一组“长周期、低频率”的定时任务,不要为每个任务单独创建timer_list,那样会消耗大量 timer 节点并加重 timer_base 的查找负担。更好的做法是只用一个 timer_list 作为心跳(比如每秒一次),然后遍历你的任务链表判断每个任务是否到期。这种“单心跳 + 任务链表”的模式,正好也是 timer wheel 多级槽位思想的简化版本,非常稳。
希望这篇文章能帮你彻底搞懂 timer wheel 定时器是怎么回事。下次再看到mod_timer,你应该能想象到它背后那个不断旋转的、五层嵌套的时间轮了。