问题的背景

服务端常常需要维护大量定时任务:连接超时回收、会话过期、重试调度、缓存淘汰等等。如果每个任务都对应一个独立的定时器,任务数量一多,定时器的管理与触发开销就会变得非常可观。

时间轮提供了一种更省资源的思路:只启动一个定时器,用很少的开销支撑大量定时任务

时间轮的基本结构

它的思路非常直观。只需要启动一个定时器,这个定时器就跟时钟一样转动,一次只跳动一个固定的时间,这个固定时间叫做 tick。假设 tick 为 1 秒,一轮的槽位数为 60,那它和现实生活中的时钟就没有什么两样了。

把这个结构对应起来就是:

  • tick:时间轮每次推进的最小时间单位,决定了定时的精度;
  • 槽位数:一轮包含多少个格子,决定了单轮能覆盖的时间跨度;
  • 指针:当前位置,随着 tick 不断向前移动;
  • :每个格子挂载一组到期时间落在该槽的定时任务。

任务是怎么挂上去的

核心操作只有一步:把定时任务加入到相应过期时间的槽当中

比方说当前指针指向的位置为 pos,那么要在 3 秒后过期一个任务,只需要在下标为 pos+3 的槽中挂上这个定时任务即可。当指针按频率转动到该槽的时候,这个槽上的所有任务都被通知过期。

这样就实现了一个 timer 支撑多个定时任务的效果。在服务端中,这往往可以节省大量的 CPU。

添加任务的复杂度与计算槽位下标同阶,基本上是常数级;由于只需要一个底层定时器,操作系统层面的定时器数量不随任务数增长,这正是它省资源的关键。

与「每个任务一个定时器」的对比

  • 底层定时器数量:时间轮只有一个,任务数不影响底层资源占用;
  • 触发时机精度:时间轮的精度受 tick 限制,任务实际触发时间会落在所属 tick 上,误差不超过一个 tick;
  • 取消任务:从槽中摘除对应节点即可,无需与底层定时器交互;
  • 空转成本:即使某个 tick 内没有任何任务到期,时间轮也只是推进指针,代价极低。

工程上需要额外处理的两点

超出单轮跨度的任务

如果 tick 为 1 秒、槽位为 60,那么单轮只能表达 60 秒以内的定时。对于更长时间的任务,常见做法有两种:一是记录任务需要绕过的轮数,指针每经过一次就递减,减到零才真正触发;二是采用多级时间轮,类似水表或时钟的秒针分针,低层级转满一圈触发高层级前进一格。

槽内任务的组织

同一个槽上可能挂载多个任务,因此槽需要一个能够快速增删的数据结构,通常使用双向链表。任务被取消时需要能从链表中安全摘除,这要求节点持有足够的信息以完成 O(1) 删除。

参考实现

如果想看具体的实现细节,可以参考开源实现,例如 siddontang/go 中的 timingwheel。代码量不大,但把 tick 推进、槽位挂载与任务触发这几个核心环节都体现得很清楚。

小结

时间轮的价值不在于算法复杂,而在于把「很多个定时器」折叠成「一个定时器加一张任务表」。在需要维护大量短周期定时任务的场景下,这个简单结构能带来非常直接的资源收益。理解它的关键就是抓住三件事:tick 决定精度、槽位数决定单轮跨度、任务按过期偏移量挂载到对应槽。

时间轮核心要素
tick时间轮推进的最小时间单位,决定定时精度与触发误差上限
槽位数一轮包含的格子数,与 tick 相乘决定单轮覆盖的时间跨度
挂载规则过期时间为当前指针后 n 个 tick,则挂到下标 pos+n 的槽上
触发方式指针转动到某槽时,该槽上所有任务被通知过期
超长定时通过记录剩余轮数递减,或采用多级时间轮来覆盖更长时间跨度