时间轮算法设计与实现

581 阅读10分钟

b146c2c86276ad71e35869515360aa75.png

前言

我们想象,如果定时任务的任务队列采用链表来实现,队列中的任务都包含任务执行的时间戳信息,我们通过不断的轮询来判断任务是否需要执行
但是这么做有一个问题,比如我们有100任务,那么定时任务队列就每次要扫100任务来确定任务是否要执行,这样会造成大量的空轮询

本文要介绍的时间轮算法,不会遍历所有任务,而是遍历时间刻度,当发现某一刻度上有任务(队列不为空)时,就会执行当前刻度上的所有任务。

时间轮算法(Timing Wheel Algorithm) 是一种高效的定时任务调度算法,通过将时间划分为固定间隔的“槽位”(slot),并利用一个“指针”(cursor)按固定周期移动,批量触发到期任务的执行。其设计灵感来源于钟表的指针运动,因此得名“时间轮”

本文将分析时间轮算法的数据结构,整体流程,和其他定时任务实现相比为什么高效,有什么局限性
重点介绍单层时间轮,再引出多层时间轮,分析其如何解决单层时间轮的空转问题
最后走读一个经典的时间轮开源实现

单层时间轮

数据结构

  1. 一个环形数组:时间轮主体,划分时间区间并承载任务
    1. 数组中每个元素称为 “槽位(Slot)”,每个槽位对应一个固定的时间间隔(如 1 秒、10 毫秒)
    2. 数组长度(槽位总数)决定了单级时间轮的覆盖范围:最大覆盖时间 = 槽位步长 × 槽位总数(如 60 个槽 ×1 秒 = 60 秒)。
  2. 当前指针cur:标记当前时间轮的 “当前时刻”,触发到期任务的执行
    1. 指针按固定频率(等于槽位步长,如每 1 秒)顺时针移动,每次移动 1 个槽位
    2. 指针指向的槽位即为 “当前到期的时间区间”,该槽位中的所有任务会被触发执行
  3. 槽位内的任务容器:存储每个槽位对应的定时任务,支持批量取出和执行
    1. 每个槽位通常关联一个双向链表,同一槽位的任务到期时间在同一时间区间内(如都在 “第 3 秒” 到期)。
    2. 当指针指向该槽位时,遍历链表并执行所有任务,执行后清空
    3. 每个节点包含以下字段:
      • circle:任务需要经过多少个完整的周期才能执行。每次时间轮转动(即一个槽位被处理),如果任务的 circle 值大于 0,则减少 1;当 circle 为 0 时,表示任务可以执行
      • job:要执行的任务
  4. map:快速定位任务在槽位中的位置,用于支持 O (1) 复杂度的删除任务操作
    1. key:任务的key,value:任务所在双向链表的节点
    2. 要删除任务时,根据key从map定位到节点,就能从双向链表中删除自己。再从map删除当前任务key,就彻底删除任务

image.png


新增任务

当要往时间轮插入一个延时任务时,需要经过以下步骤:

  1. 根据任务的延迟时间 task.delay,总共的槽数量slotNum,槽的时间间隔interval 计算出:
    1. pos:任务应该被放置的槽位索引
    2. 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

  1. 任务添加到指定槽位的链表末尾
  2. 往map记录任务key和双向链表节点的映射关系

执行任务

  1. 定时器每隔一定时间触发,推进当前指针到时间轮的下一个槽位
  2. 取出该槽位的所有任务,并发执行

为啥高效?

用时间轮执行定时任务为啥高效?我们对比时间轮和另一个经典的定时任务实现:最小堆。从插入删除任务,任务到期执行的角度,比较两者的时间复杂度

插入删除任务

  • 插入任务,时间复杂度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 的开销会变得显著

任务到期执行

  1. 取出该槽对应的整个任务链表 O(1)
  2. 执行链表中所有任务(对于单层时间轮,这些任务都已到期)

以上两步操作,第一步O(1),第二步虽然遍历整个链表,但这些任务都是要执行的,因此没有多余的操作

对比最小堆:

  • 优先队列/最小堆: 获取并删除最小元素(最近要触发的任务)需要 O(log N) 的时间
操作时间轮
添加任务O(1)O(log n)
删除任务O(1)O(log n)
触发任务O(k)(k为当前槽位任务数)O(log n)

牺牲了哪些特性?

时间轮的高效并非免费午餐,有以下局限性:

  1. 更高的内存占用 (牺牲空间)
    1. 时间轮: 需要预先分配一个固定大小的数组(槽位),即使很多槽位是空的。例如,一个 1 秒精度、支持最大 1 小时延迟的单层时间轮需要 3600 个槽位,即使只有几个任务,也需要占用3600个槽位的空间
    2. 对比最小堆: 只按需分配存储实际任务节点的内存。内存占用与当前任务数 N 成正比
    3. 因此对于最大延迟范围大但实际任务稀疏的场景,时间轮可能浪费大量内存。
  2. 时间精度受限
    1. 时间精度受槽位步长限制。例如步长 1 秒,那精度就是秒级
    2. 对比最小堆: 最小堆对任务延迟时间没有任何精度或范围的限制,可以处理任意时间点(例如纳秒级精度)的任务

以上两个问题是相互制约的关系。如果要内存占用小,那么tick之间的间隔就要大,就会牺牲时间精度

  1. 空转问题
    1. 单层时间轮,其时间覆盖范围由槽位步长 × 槽位总数决定,假设为 60 槽 ×1 秒 = 60 秒
    2. 若大量任务延迟是10分钟,需通过circle记录循环次数=10。但每次指针移动仍需遍历每个槽的所有任务,检测circle是否为0,如果不是,将circle-1。只有circle=0才表示任务到期,需要执行。这这个case中,前面9轮都不是0,导致空转问题

image.png


多层时间轮

下面要介绍的多层时间轮可以解决单层时间轮的空转问题

  • 其结构为:多层环形数组,每层时间粒度递增(如秒级、分钟级、小时级)
  • 任务分配流程为:
    • 短周期任务直接放入低层时间轮(如秒级)。
    • 长周期任务放入高层时间轮(如分钟级),当高层时间轮触发到某个槽时,将任务转移到低层时间轮。
  • 举个例子:
    • 第一层秒轮:60 个槽位,每个槽位之间间隔 1 秒(覆盖 0~59 秒)
    • 第二层分轮:60 个槽位,每个槽位之间间隔 1 分钟(覆盖 0~59 分钟)
    • 任务分配逻辑:
      • 若任务需在 70 秒后执行(70 秒 = 1 分钟 + 10 秒),则先放入第二层时间轮的slot2(从1开始),当第二层指针移动到slot2 时,将任务转移到第一层的槽位 10

image.png

回到空转问题,看多层时间轮是怎么解决的:

大量延迟10分钟的任务加入时,会被放入分轮的第10个槽位。前面9分钟时间轮在推进时,都不会做任何遍历双向链表的操作。只有当第10分钟时,任务会从分轮转移到秒轮,才会开始真正遍历双链表,执行任务,避免了大量无效的空转问题


源码走读

比较好的开源的单层时间轮实现有两个:

这两个实现原理差别不大,本文选择分析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
    }
}