☰
Linux内核timer wheel机制详解:从数据结构到性能调优
2026/10/28 7:17:58 网站建设 项目流程

1. 从tick到timer wheel:内核定时器为什么要绕这么多弯

很多人第一次看内核定时器相关代码时,最大的困惑是:timer_list+mod_timer+del_timer这套接口明明用起来挺顺手,为什么内核非要搞出一个叫timer wheel的东西?从命名到数据结构都让人摸不着头脑。我在刚接触这部分源码时也有同样的疑问,直到有一次用ftrace跟踪一个驱动程序的高频定时器超时分布,才意识到这里头的复杂度远超直觉。

先明确一个概念:Linux内核里的定时器并不是"到点自动执行一个函数"这么简单。它的底层驱动来自硬件时钟中断,而开发者见到的add_timer、mod_timer这类API都只是对内部timer wheel的封装。也就是说,真正决定定时器性能、精度、扩展性的核心机制,是timer wheel怎么保存、查找、触发这些定时器。而用户态的timerfd、epoll的超时、网络协议栈的TCP重传、块设备的I/O超时,最终都会落到这一层。

直接说结论:timer wheel用的是多级哈希轮结构,这也是名字里"wheel(轮)"的由来。内核默认是5级,每一级是一个定长的数组,每个数组元素挂一条定时器链表。低级轮负责近期的定时器,粒度细;高级轮负责远期定时器,粒度粗。定时器到期后逐级"降级"到低级轮,最终触发回调。这套做法本质上是在"插入快"和"扫描快"之间找了个工程平衡点——毕竟内核面对的定时器数量可能成千上万,每个tick都从头到尾检查一遍所有定时器,早就扛不住了。

提示:如果你只看过kernel/time/timer.c里add_timer的代码,建议先从kernel/time/timer_list.c的输出对应着看,这样对timer wheel的整体布局会更有画面感。

这篇文章我不会把整份源码从头抄一遍,而是挑几个真正重要的点逐一拆:数据结构怎么组织、"轮转"的时机和逻辑是怎么衔接的、为什么会存在"过期定时器延迟执行"这种看起来违反直觉的现象,以及在驱动开发里最容易踩哪些坑。最后再讲讲调试定时器相关问题时的经验和工具。

2. timer wheel的数据结构与"为什么是5级"

timer wheel的精髓,全在kernel/time/timer.c里的struct timer_base以及tvec_base那套结构上。每个CPU都有一个独立的timer_base,这样做的好处是:定时器的添加、删除、触发都在本CPU上进行,不需要加全局锁,避免了多核竞争。这个设计在SMP普及的今天尤其重要,因为定时器是内核里调用频率最高的机制之一,任何一点锁竞争都会被放大。

来看关键的层级定义:

#define TVN_BITS 6 #define TVR_BITS 8 #define TVN_SIZE (1 << TVN_BITS) // 64 #define TVR_SIZE (1 << TVR_BITS) // 256 #define TVN_MASK (TVN_SIZE - 1) #define TVR_MASK (TVR_SIZE - 1) struct timer_base { raw_spinlock_t lock; struct timer_list *running_timer; unsigned long clk; unsigned long next_expiry; ... struct hlist_head vectors[LVL_DEPTH][LVL_SIZE]; // 实际是5级数组 };

第一个阵列(level 0)有256个桶,每个桶对应1个jiffy的精度。后面每一级是64个桶,分别对应256、16384、1048576、67108864个jiffy的范围。这里用LVL_DEPTH表示层级深度(默认5),LVL_SIZE表示每级的桶数量(默认64)。如果只算桶的总数,是256+64+64+64+64=512个哈希桶,看似不多,但因为每级桶的"跨度"不同,实际能覆盖的时间范围大得惊人:一直到2^32个jiffy,也就是大约497天(假设HZ=1000)。也就是说,mod_timer传一个很远很远的时间点也不会溢出,这是老式的timer wheel按HZ范围硬编码所做不到的。

层级间是怎么"接上"的呢?关键在于LVL_START和LVL_SIZE的宏展开。内核通过lvls和level计算函数,把一个expires值分解成"层级数 + 偏移量":

static inline int calc_index(unsigned long expires, unsigned int lvl) { return (expires >> (LVL_SHIFT(lvl))) & LVL_MASK; }

LVL_SHIFT随层级线性增加:第0级偏移是0,第1级偏移是6(因为第一级用6位索引64个桶),第2级偏移是12,依次类推。这个"右移+位与"的过程,本质上就是在做"取模"操作——只不过因为桶大小全是2的幂,位运算比除法快得多。

再仔细看,这5级的有效时间范围分别是:

层级桶数量覆盖宽度时间表示范围(HZ=1000)
02561 jiffy/桶0 ~ 255 ms
164256 jiffy/桶256 ms ~ 16.384 s
26416384 jiffy/桶16.384 s ~ 17.45 min
3641048576 jiffy/桶17.45 min ~ 18.6 h
46467108864 jiffy/桶18.6 h ~ 497 days

有了这个表,你再去看内核里__internal_add_timer的代码,会发现它做的事情很简单:从第0级开始找,一旦发现expires能落在某一级桶的覆盖范围内,就把它挂进那个桶。选级的依据不是看expires >> level算出来等于多少,而是看它能不能在当前级内"放下"。这个规则保证了一个定时器始终被存放到能满足自身精度要求的最细粒度层级。

但在实际阅读源码时,我建议你留意timer_list结构体里还有两个容易被忽视的字段:flags和idx。flags里除了用于RCU等场景的位标记,还记录了定时器的CPU绑定信息(TIMER_PINNED)以及是否已经入队。idx则保存了当前所在桶的索引,这个索引在删除定时器时非常关键——否则每次删除都要从第0级开始重新计算一次它应该在的位置。有了idx,detach_timer能在O(1)时间内把节点从链表上摘下来。这也是timer wheel性能好的另一个原因:添加和删除都是O(1)或者O(level),而不是O(n)。

如果你自己尝试画一下这个数据结构,会发现它本质上是一个"时间维度上的基数树"——把expires这个32位值按位拆成5段,每段作为哈希索引。这个设计的一个副作用是:同一个桶里的定时器,过期时间并不严格相等,而是都落在桶所表示的时间跨度内。这也为后面"到期但尚未执行"的延迟现象埋下了伏笔。

3. 轮转机制:clk、vec和"级间降级"的完整流程

搞清楚结构之后,最核心的问题是:定时器什么时候从高级别降落到低级别?这背后的逻辑在__run_timers和run_timer_softirq里。过去在一个tick进来时,内核会从第0级开始逐级检查,但现在由于采用了级联设计,逻辑可以简化很多。

简化后的流程大概是这样的:

  1. 每次时钟tick触发run_timer_softirq(软中断)。
  2. 把base->clk加1,clk是长期维护的一个"逻辑当前时间",未必等于实时jiffies。
  3. 检查当前clk在第0级对应的桶是否有定时器;有则依次取出执行。
  4. 当第0级的所有桶都走了一圈(也就是256个tick),"轮子"转了一整圈,这时第1级会有一个桶被"降级"到第0级。降级的本质是把那一桶里的定时器全部重新计算索引,分散挂到第0级对应的桶中。
  5. 类似地,第1级转完一圈,第2级降级一个桶到第0级/第1级;依此类推。

这里面最容易被误解的是"逐级降级"到底发生在哪个时机。网上的文章说得含糊,很容易让人误以为每次clk递增都要执行一次级联。实际上,只有当某级的花名册(roster)指针走完一整圈时,才会触发更高一级的级联操作。__run_timers里那个while (time_after_eq(jiffies, base->clk))的循环,配合cascade逻辑保证了这种周期性。

cascade这个函数名取得很形象:水从高处往低处流,定时器也像水一样从高级别桶"流"向低级别桶。它的源码实现是:

static int cascade(struct timer_base *base, struct timer_list *timer, unsigned int level) { struct hlist_head *tv = &base->vectors[level]; struct hlist_node *entry; struct timer_list *tmp; ... hlist_for_each_entry_safe(timer, tmp, tv + index, entry) { hlist_del_init(&timer->entry); internal_add_timer(base, timer); } ... }

这里有个肉眼可见的性能成本:当第1级的一个桶被降级到第0级时,这个桶里的所有定时器都要重新internal_add_timer,再计算一次哈希索引。如果某个桶里的定时器特别多,cascade就会占用比较长的CPU时间。理论上,一个恶意驱动可以创建大量短周期的定时器,让它们都堆在同一桶里,导致cascade时间显著拉长。不过正常驱动不会这么干,因为定时器数量本身会受到系统内存和调用频率的限制。

为了降低这种级联带来的延迟波动,新版本内核引入了LVL_CLK_MASK等优化,以及"惰性降级":不立刻把一个过期桶里的所有定时器级联到低级,而是在collect_expired_timers阶段只摘取真正到期的那些定时器,大部分情况下可以避免把整个桶重新散列。这个细节是看timer.c时最容易忽略的。

另一个值得关注的是base->clk和jiffies之间的微妙关系。clk是内核定时器子系统的私有时钟,通常等于jiffies,但它的更新受软中断调度影响。也就是说,软中断被延迟处理时,clk会滞后于jiffies,而__run_timers的while循环会尽量追赶,在同一个软中断里处理多个tick的定时器。这时你观测到的现象就是:明明定时器设的是10个jiffies后触发,实际触发可能变成12个甚至20个jiffies之后。这引出了下一节要说的"定时器精度"话题。

4. 定时器的"延迟"是feature不是bug:为什么你等不到精准超时

很多做驱动开发的人都碰到过这样的场景:配置了一个1ms的周期定时器,结果示波器量出来触发间隔是1.x毫秒甚至3毫秒,于是怀疑是不是驱动写错了。其实,内核定时器从来不做"精确到点"的保证。它承诺的只是"至少不会比你要求的更早触发"。这个语义叫deferrable(可延迟),对,就是字面意思。

原因有三层:

  1. 软中断的延迟:定时器回调是在TIMER_SOFTIRQ上下文中执行的。如果当前CPU正忙,或者软中断被硬中断屏蔽,软中断的执行就会推迟。run_timer_softirq的调用时机由内核的irq_exit路径决定,并不是tick中断一进来就立刻处理。

  2. 没有"到期即刻执行"的硬性机制:timer wheel在第0级桶的粒度是1个jiffy(假设HZ=1000)。如果你在第10个jiffy挂一个定时器,expires设为第11个jiffy,它会在第11个jiffy的软中断中触发。但如果软中断在那一瞬间刚好被延迟,表现就是"晚到"而非"早到"。内核保证它不会提前触发,因为每次处理时都会检查time_before(expires, clk)这种时间比较。

  3. 级联延迟:高层的定时器降级时,不是一个个算好精确时间散列到桶里,而是带着一个桶的时间跨度整体下降。所以一个第3级的定时器在降级到第2级时,它的精度就已经被"抹平"了——例如第3级一个桶跨度是1048576个jiffy(约17分钟),那这批定时器的实际触发时间可能相差很大。这就是为什么"短周期定时器尽量别用大跨度"的原因:它们之间隔了几级,精度被损耗掉了。

在内核源码里,专门为deferrable定时器做的处理是TIMER_DEFERRABLE标志位。这类定时器在CPU进入idle状态时不会触发,从而避免把CPU从空闲唤醒。典型应用场景是网络栈中的TIME_WAIT清理等不那么紧急的任务。把这个标志理解成"可以再等等",你就明白了为什么timerfd在用户态设置超时时间时,有时会比预期晚很多毫秒触发——这个时间差可能来自底层deferrable定时器。

实际开发中,我给出的建议是:

  • 如果要求毫秒级以内的精度(比如PWM波形控制、周期性数据采样),不要用内核timer,应该用hrtimer。
  • 用timer_list时,把回调函数设计成"幂等可重入"的风格,因为它可能重入(比如在SMP上多个CPU同时调用同一个定时器回调,如果该定时器设置了TIMER_PINNED则通常不会)。
  • 不要在定时器回调里做耗时操作。定时器回调是在软中断上下文执行的,同一个CPU上的所有定时器都共用这一个路径。一个回调耗时过长,会导致后续所有定时器持续"晚点"。

还有个小技巧:如果你确实需要周期性任务,不推荐用mod_timer在回调末尾重新挂载自己。看起来简单,但它每次都得走一遍timer wheel的添加流程,并且如果上一个实例还在运行,mod_timer可能会被卡住。更稳的做法是用timer_reduce或者hrtimer的HRTIMER_MODE_REL模式,从源头避免反复插入移除的开销。

5. timer wheel的性能极限:插入、删除、触发的最坏情况分析

内核任何核心机制都逃不过"最坏情况"这三个字。timer wheel虽然平均时间复杂度低,但有两个方面的性能需要特别关注:哈希冲突和级联放大。

先说哈希冲突。因为每个CPU的timer wheel是独立的,不同CPU之间天然隔离冲突。但同一个CPU上,如果大量定时器恰好落到同一个桶里(比如你同时启动100个网络连接,它们的超时时间都设为5秒,在HZ=250时,第0级桶粒度是4ms,第1级桶粒度是1秒,5秒落在第1级桶的某个索引上),这100个定时器会被串在一个链表上。触发时从链头走到链尾,逐个执行回调。这不是灾难,但会导致这批定时器互相拖慢。

最坏情况发生在级联时的"雪崩":一个高层级桶里挂了几千个定时器,降级瞬间把它们全部重新散列。cascade是串行完成的,期间整个timer wheel的锁是持有的,其他CPU访问不到这个timer_base(考虑到每个CPU独立,通常只有本CPU受影响)。在内核里,cascade函数体的循环对每个定时器都要调用一次internal_add_timer,而internal_add_timer又要根据expires计算新索引。如果有一个驱动把几千个定时器设到完全相同的过期时间点,级联那一瞬间的CPU开销会显著飙高。

我做过一个测试:在一块四核ARM平台上,让一个驱动程序同时创建5000个timer,expires都指向同一个未来的tick。结果发现cascade阶段的软中断耗时能达到几百微秒,虽然还不至于导致系统级卡顿,但如果你正好在做声音采样或者低延迟控制,这种尖峰是完全不可接受的。

为了避免这个问题,真实产品中往往会做"定时器合并":把多个相同过期时间的请求合并成一个定时器,触发后统一处理。TCP协议栈就是这么干的——它维护一条"early retransmit"定时器,而不是每个连接一个独立定时器。底层逻辑相同:把大量短周期定时器变成少量中长周期定时器,减少对timer wheel的压力。

操作平均时间最坏时间备注
添加定时器O(1)O(level)需级联时可能遍历层级
删除定时器O(1)O(1)有idx时直接从链表摘除
触发扫描每tick O(1)O(冲突桶大小)受同一桶链表长度影响
级联O(桶内定时器数)O(桶内定时器数)可能成为延迟尖峰来源

在工程上,这些边的存在并不是bug,而是"投递快"和"执行准"之间的权衡。如果你在意延迟峰值,可以考虑把定时器分组管理:关键业务用高精度定时器,非关键业务合并到低频批量处理。这样可以把timer wheel的触发频率压低,尖峰也会小得多。

6. 实践排查:用tracepoint和crash工具定位定时器问题

光讲原理不给排查手段,等于白讲。我在实际工作中确实遇到过几次和timer wheel相关的疑难杂症,这里分享两个相对有价值的排查思路。

第一个是用内建的tracepoint追踪定时器生命周期。内核在timer_start、timer_expire_entry、timer_expire_exit、timer_cancel等位置埋了tracepoint,可以通过trace-cmd或者perf直接观测。比如你想知道某个驱动创建的定时器到底什么时候入队、什么时候触发、回调跑了多久,可以这样:

perf record -e timer:timer_start -e timer:timer_expire_entry -e timer:timer_expire_exit -a sleep 5 perf script

输出里能看到timer_list的地址、expires值、回调函数名,以及回调开始和结束的时间戳。把时间戳相减,就得到每次回调的实际执行时长。有一次我排查一个网卡驱动的收包超时问题,就是通过这个tracepoint发现该驱动的定时器回调里居然做了mdio总线读写,单次耗时都超过1毫秒,导致整体收包延迟异常。定位后把mdio操作移到工作队列,问题立刻消失。

第二个排查手段是使用crash工具查看定时器的散落情况。如果系统出现了疑似定时器风暴的问题(比如CPU soft lockup),crash进去后用timer命令列出所有定时器:

crash> timer

这个命令会把系统每个CPU上timer wheel里所有定时器按expires时间排序打出来。结合-t选项可以看到更详细的信息。如果发现某一个桶里的定时器数量上千,就基本可以判定是哈希冲突严重,或者某个驱动把大量定时器设到了同一个时间点。

这类问题常见的根因有两类:

  1. 驱动程序用同一个timer_list结构体,却连续多次mod_timer,导致前一个回调还没执行,新的又挂上去了,最终在某次回调里出现数据竞争。
  2. 回调函数内部调用del_timer_sync,但该定时器又是在软中断里自删除自重新挂载的——del_timer_sync在中断上下文不能使用,会触发BUG或者死锁。

针对第二类,我的建议是:如果需要在定时器回调里重新调度,用mod_timer就行;如果需要在另一个上下文中取消定时器并等它跑完,才用del_timer_sync。这两者的语义区分经常被忽略,但恰恰是驱动稳定性的关键。

7. 从5级到LVL:新内核的timer wheel演进方向

最后简单聊聊这个机制近几年的演进。老代码里tvec_base_s的写法是固定5级,但后来为了支持更长的超时范围(尤其是在休眠/挂起场景下),内核改用了LVL_DEPTH宏,把层级数做成可配置。你可以通过CONFIG_BASE_SMALL来压缩桶的数量,这在高密度嵌入式环境里有用。

CONFIG_BASE_SMALL这项在很多人眼里不起眼,但我建议有极致性能需求的场景打开它做个基准测试。小于等于1的情况下,第0级的桶数从256缩到32,第1级及以上的桶数从64缩到16。带来的好处是cache footprint大幅降低,但代价是哈希冲突变多。如果你的系统定时器数量不多,打开它确实能减少内存占用和Cache Miss;如果定时器成千上万,就别开。

另一个演进是timer_migration相关的优化。旧版内核里每CPU一个timer_base,如果定时器在CPU A创建但到期时CPU A正在忙,软中断的调度效率会受影响。新版本引入了TIMER_PINNED标志,允许普通定时器在CPU之间迁移,让负载均衡器把软中断搬到更空闲的CPU上处理。这也是为什么在新内核上,perf观察到的软中断行为会和旧内核有明显差异。

对于正在搭建嵌入式系统的开发者,我的建议是别盲目追逐最新版。timer wheel这层如果没改好,很容易引入回归。先摸清自己场景的定时器分布模型——是周期大量短定时器,还是偶发少量长定时器——再决定要不要调整层级参数、要不要打开CONFIG_BASE_SMALL。内核的.config里关于timer的选项不多,但每一个都值得认真评估。

8. 我与timer wheel缠斗后的几点体会

说起来,timer wheel是我在内核源码里花时间最多、也最"反直觉"的模块之一。它说难不难——无非是一组哈希桶加若干级联规则;但说简单也真不简单——任何一个小改动都可能牵动系统全局的定时精度和吞吐。

我踩过最大的一个坑,是在某个数据采集设备上,为了让采样周期更精确,我把驱动程序里所有定时器全部换成了hrtimer。结果虽然采样间隔稳定了,系统整体负载却上升了十几个百分点,原因就是hrtimer在每个tick之外还会额外触发大量高精度中断,把这些中断累积起来,反而拖垮了CPU。后来我又换回timer wheel,只在最关键的一路采样上保留hrtimer,两全其美。

还有一次在产品调试时发现,某个外设驱动的mod_timer调用频率特别高,几乎每个数据包都触发一次。由于该驱动跑在中断上下文,mod_timer本身要拿timer_base的锁,同一CPU上其他定时器操作全被阻塞。那时还没意识到这是性能瓶颈,后面看scheduling数据才恍然大悟——有些模块的"性能事故"不是算法本身慢,而是锁竞争和Cache Miss造成的。

如果你正打算深入读kernel/time/timer.c的源码,我建议按这个顺序来:先看timer_base和init_timer,理清每个CPU的私有状态;再看internal_add_timer,确认层级计算和索引确定的逻辑;接着看run_timer_softirq和__run_timers,搞清楚软中断入口和过期处理流程;最后再回头看cascade,你会发现前面所有疑惑在这时全部串起来了。

源码读起来确实有些绕,但这套机制至今仍是Linux内核里最优雅、最高效的定时器实现之一。把它啃下来,你对内核的"时间感知"能力会有质的提升,也能在驱动开发时少走很多弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询