前言
我们想象,如果定时任务的任务队列采用链表来实现,队列中的任务都包含任务执行的时间戳信息,我们通过不断的轮询来判断任务是否需要执行
但是这么做有一个问题,比如我们有100任务,那么定时任务队列就每次要扫100任务来确定任务是否要执行,这样会造成大量的空轮询
本文要介绍的时间轮算法,不会遍历所有任务,而是遍历时间刻度,当发现某一刻度上有任务(队列不为空)时,就会执行当前刻度上的所有任务。
时间轮算法(Timing Wheel Algorithm) 是一种高效的定时任务调度算法,通过将时间划分为固定间隔的“槽位”(slot),并利用一个“指针”(cursor)按固定周期移动,批量触发到期任务的执行。其设计灵感来源于钟表的指针运动,因此得名“时间轮”
本文将分析时间轮算法的数据结构,整体流程,和其他定时任务实现相比为什么高效,有什么局限性
重点介绍单层时间轮,再引出多层时间轮,分析其如何解决单层时间轮的空转问题
最后走读一个经典的时间轮开源实现
单层时间轮
数据结构
- 一个环形数组:时间轮主体,划分时间区间并承载任务
- 数组中每个元素称为 “槽位(Slot)”,每个槽位对应一个固定的时间间隔(如 1 秒、10 毫秒)
- 数组长度(槽位总数)决定了单级时间轮的覆盖范围:
最大覆盖时间 = 槽位步长 × 槽位总数(如 60 个槽 ×1 秒 = 60 秒)。
- 当前指针cur:标记当前时间轮的 “当前时刻”,触发到期任务的执行
- 指针按固定频率(等于槽位步长,如每 1 秒)顺时针移动,每次移动 1 个槽位
- 指针指向的槽位即为 “当前到期的时间区间”,该槽位中的所有任务会被触发执行
- 槽位内的任务容器:存储每个槽位对应的定时任务,支持批量取出和执行
- 每个槽位通常关联一个双向链表,同一槽位的任务到期时间在同一时间区间内(如都在 “第 3 秒” 到期)。
- 当指针指向该槽位时,遍历链表并执行所有任务,执行后清空
- 每个节点包含以下字段:
circle:任务需要经过多少个完整的周期才能执行。每次时间轮转动(即一个槽位被处理),如果任务的 circle 值大于 0,则减少 1;当 circle 为 0 时,表示任务可以执行job:要执行的任务
- map:快速定位任务在槽位中的位置,用于支持
O (1)复杂度的删除任务操作- key:任务的key,value:任务所在双向链表的节点
- 要删除任务时,根据key从map定位到节点,就能从双向链表中删除自己。再从map删除当前任务key,就彻底删除任务
新增任务
当要往时间轮插入一个延时任务时,需要经过以下步骤:
- 根据任务的延迟时间
task.delay,总共的槽数量slotNum,槽的时间间隔interval计算出:pos:任务应该被放置的槽位索引circle:任务需要经过的完整时间轮周期数
具体怎么计算?下面举两个例子说明,假如interval = 1s,slotNum=10
示例 1:delay = 5s
circle = 5(delay) / 1(interval) / 10(slotNum) = 0(因为 5 秒小于一个完整周期)
pos = (0 + 5 / 1) % 10 = 5
示例2:delay = 15s
circle = 15 / 1 / 10 = 1(因为 15 秒超过了一个完整周期),需要时间轮转过一轮后,第二轮才会触发
pos = (0 + 15 / 1) % 10 = 5
- 任务添加到指定槽位的链表末尾
- 往map记录任务key和双向链表节点的映射关系
执行任务
- 定时器每隔一定时间触发,推进当前指针到时间轮的下一个槽位
- 取出该槽位的所有任务,并发执行
为啥高效?
用时间轮执行定时任务为啥高效?我们对比时间轮和另一个经典的定时任务实现:最小堆。从插入删除任务,任务到期执行的角度,比较两者的时间复杂度
插入删除任务
- 插入任务,时间复杂度
O(1)- 计算该任务应该放入哪个槽:
槽索引 = (当前指针位置 + 延时时间 / 槽间隔) % 槽总数O(1) - 将任务直接添加到计算出的目标槽对应的任务链表中 O(1)
- 计算该任务应该放入哪个槽:
- 删除任务,时间复杂度
O(1)- 通过hash表拿到任务key在哪个slot,以及链表节点元素element。有element,就能将自己从所属slot的链表中删除 O(1)
- 从hash表中删除该任务key O(1)
对比最小堆:
- 优先队列/最小堆: 插入,删除任务都需要
O(log N)的时间复杂度(N 是队列中任务数),因为需要维护堆结构。在任务数量巨大(N 很大)时,log N的开销会变得显著
任务到期执行
- 取出该槽对应的整个任务链表
O(1) - 执行链表中所有任务(对于单层时间轮,这些任务都已到期)
以上两步操作,第一步O(1),第二步虽然遍历整个链表,但这些任务都是要执行的,因此没有多余的操作
对比最小堆:
- 优先队列/最小堆: 获取并删除最小元素(最近要触发的任务)需要
O(log N)的时间
| 操作 | 时间轮 | 堆 |
|---|---|---|
| 添加任务 | O(1) | O(log n) |
| 删除任务 | O(1) | O(log n) |
| 触发任务 | O(k)(k为当前槽位任务数) | O(log n) |
牺牲了哪些特性?
时间轮的高效并非免费午餐,有以下局限性:
- 更高的内存占用 (牺牲空间) :
- 时间轮: 需要预先分配一个固定大小的数组(槽位),即使很多槽位是空的。例如,一个 1 秒精度、支持最大 1 小时延迟的单层时间轮需要
3600个槽位,即使只有几个任务,也需要占用3600个槽位的空间 - 对比最小堆: 只按需分配存储实际任务节点的内存。内存占用与当前任务数
N成正比 - 因此对于最大延迟范围大但实际任务稀疏的场景,时间轮可能浪费大量内存。
- 时间轮: 需要预先分配一个固定大小的数组(槽位),即使很多槽位是空的。例如,一个 1 秒精度、支持最大 1 小时延迟的单层时间轮需要
- 时间精度受限
- 时间精度受槽位步长限制。例如步长 1 秒,那精度就是秒级
- 对比最小堆: 最小堆对任务延迟时间没有任何精度或范围的限制,可以处理任意时间点(例如纳秒级精度)的任务
以上两个问题是相互制约的关系。如果要内存占用小,那么tick之间的间隔就要大,就会牺牲时间精度
- 空转问题
- 单层时间轮,其时间覆盖范围由
槽位步长 × 槽位总数决定,假设为 60 槽 ×1 秒 = 60 秒 - 若大量任务延迟是10分钟,需通过circle记录循环次数=
10。但每次指针移动仍需遍历每个槽的所有任务,检测circle是否为0,如果不是,将circle-1。只有circle=0才表示任务到期,需要执行。这这个case中,前面9轮都不是0,导致空转问题
- 单层时间轮,其时间覆盖范围由
多层时间轮
下面要介绍的多层时间轮可以解决单层时间轮的空转问题
- 其结构为:多层环形数组,每层时间粒度递增(如秒级、分钟级、小时级)
- 任务分配流程为:
- 短周期任务直接放入低层时间轮(如秒级)。
- 长周期任务放入高层时间轮(如分钟级),当高层时间轮触发到某个槽时,将任务转移到低层时间轮。
- 举个例子:
- 第一层秒轮:60 个槽位,每个槽位之间间隔 1 秒(覆盖 0~59 秒)
- 第二层分轮:60 个槽位,每个槽位之间间隔 1 分钟(覆盖 0~59 分钟)
- 任务分配逻辑:
- 若任务需在 70 秒后执行(70 秒 = 1 分钟 + 10 秒),则先放入第二层时间轮的slot2(从1开始),当第二层指针移动到slot2 时,将任务转移到第一层的槽位 10
回到空转问题,看多层时间轮是怎么解决的:
大量延迟10分钟的任务加入时,会被放入分轮的第10个槽位。前面9分钟时间轮在推进时,都不会做任何遍历双向链表的操作。只有当第10分钟时,任务会从分轮转移到秒轮,才会开始真正遍历双链表,执行任务,避免了大量无效的空转问题
源码走读
比较好的开源的单层时间轮实现有两个:
- godis:github.com/HDT3213/god…
- go-zero:github.com/zeromicro/g…
这两个实现原理差别不大,本文选择分析godis的时间轮实现,我认为其设计更加简洁
数据结构
type TimeWheel struct {
// 每个槽位之间的时间间隔
interval time.Duration
// 用于触发时间轮的定时器,每隔 interval 触发一次
ticker *time.Ticker
// 时间轮的槽位数组,每个槽位是一个链表,存储了需要在该时间点执行的任务
slots []*list.List
// 用于记录任务的位置,键是任务的唯一标识符(key)
timer map[string]*location
// 当前指针指向的槽位索引,表示当前处理到哪个时间点
currentPos int
// 时间轮的总槽数量,决定了时间轮的大小
slotNum int
addTaskChannel chan task
removeTaskChannel chan string
stopChannel chan bool
}
type task struct {
// 任务延迟执行的时间
delay time.Duration
// 任务需要经过多少个完整的周期才能执行。当 circle 为 0 时,任务可以执行。
circle int
key string
job func()
}
启动
所有的增删任务,取任务的操作,都在一个goroutine里执行,避免了并发安全问题。并且都是高效的O(1)操作,性能上也没问题
耗时高的执行任务操作是启动单独的goroutine执行,下文再分析
func (tw *TimeWheel) start() {
for {
select {
case <-tw.ticker.C:
tw.tickHandler()
case task := <-tw.addTaskChannel:
tw.addTask(&task)
case key := <-tw.removeTaskChannel:
tw.removeTask(key)
case <-tw.stopChannel:
tw.ticker.Stop()
return
}
}
增删任务
func (tw *TimeWheel) addTask(task *task) {
pos, circle := tw.getPositionAndCircle(task.delay)
task.circle = circle
e := tw.slots[pos].PushBack(task)
loc := &location{
slot: pos,
etask: e,
}
// 如果该任务已存在于时间轮中,删除
if task.key != "" {
_, ok := tw.timer[task.key]
if ok {
tw.removeTask(task.key)
}
}
tw.timer[task.key] = loc
}
// 根据给定的延迟时间 d 计算出任务在时间轮中的槽位索引 pos 和需要经过的完整周期数 circle
func (tw *TimeWheel) getPositionAndCircle(d time.Duration) (pos int, circle int) {
delaySeconds := int(d.Seconds())
intervalSeconds := int(tw.interval.Seconds())
// 计算任务需要经过的完整周期数
// delaySeconds / intervalSeconds: 计算任务需要等待的总tick次数
circle = int(delaySeconds / intervalSeconds / tw.slotNum)
// 计算任务的槽位
pos = int(tw.currentPos+delaySeconds/intervalSeconds) % tw.slotNum
return
}
// 删除任务
func (tw *TimeWheel) removeTask(key string) {
pos, ok := tw.timer[key]
if !ok {
return
}
l := tw.slots[pos.slot]
// 从slot中删除
l.Remove(pos.etask)
// 从map中删除
delete(tw.timer, key)
}
执行任务
取出当前slot的双向链表,并发执行所有任务
func (tw *TimeWheel) tickHandler() {
// 推进时间轮
l := tw.slots[tw.currentPos]
if tw.currentPos == tw.slotNum-1 {
tw.currentPos = 0
} else {
tw.currentPos++
}
// 执行当前槽的所有任务
go tw.scanAndRunTask(l)
}
func (tw *TimeWheel) scanAndRunTask(l *list.List) {
// 遍历槽内所有任务
for e := l.Front(); e != nil; {
task := e.Value.(*task)
// 如果任务的 circle 值大于 0,表示任务还需要等待一个周期
if task.circle > 0 {
// 减少任务的 circle 值
task.circle--
e = e.Next()
continue
}
// 执行任务
go func() {
defer func() {
if err := recover(); err != nil {
logger.Error(err)
}
}()
job := task.job
job()
}()
next := e.Next()
// 删除任务
l.Remove(e)
if task.key != "" {
delete(tw.timer, task.key)
}
e = next
}
}