文章目录
代码地址:https://gitee.com/lymgoforIT/golang-trick/tree/master/22-timewheel
一:简介
1. 应用场景
时间轮(TimingWheel)
算法应用范围非常广泛,各种操作系统的定时任务调度都有用到,我们熟悉的linux crontab
,以及Java
开发过程中常用的Dubbo、Netty、Akka、Quartz、ZooKeeper 、Kafka
等,几乎所有和时间有关的任务调度都采用了时间轮的思想。
时间轮是一种实现延迟功能(定时器)的巧妙算法
。如果一个系统存在大量的任务调度,时间轮可以高效的利用线程资源来进行批量化调度。把大批量的调度任务全部都绑定时间轮上,通过时间轮进行所有任务的管理,触发以及运行。能够高效地管理各种延时任务,周期任务,通知任务等。不过,时间轮调度器的时间精度可能不是很高,对于精度要求特别高的调度任务可能不太适合,因为时间轮算法的精度取决于时间段“指针”单元的最小粒度大小,比如时间轮的格子是一秒跳一次,那么调度精度小于一秒的任务就无法被时间轮所调度。
定时的任务调度分两种:
-
一段时间后执行,即:相对时间
-
指定某个确定的时间执行,即:绝对时间
当然,这两者之间是可以相互转换的,例如当前时间是12点,定时在5分钟之后(相对时间)执行,其实绝对时间就是:12:05;而定时在12:05(绝对时间)执行,相对时间就是5分钟之后执行。
2. 时间轮概念
一维时间与时间轮
聊时间轮之前,先聊聊时间的概念.
首先时间是一维、单向的,我们可以用一条一维的时间轴将其具象化. 我们对时间轴进行刻度拆分,每个刻度对应一个时间范围,那么刻度拆分得越细,则表示的时间范围越精确。
然而,我们知道时间是没有尽头的,因此这条一维时间轴的长度是无穷大的. 倘若我们想要建立一个数据结构去表达这条由一系列刻度聚合形成的时间轴,这个数据结构所占用的空间也是无穷无尽的.
那么,我们应该如何优化这个问题呢?此时,大家不妨低头看一眼自己的手表,手表或时钟这类日常生活中用来关联表达时间的工具,采用的是首尾衔接的环状结构来替代无穷长度的一维时间轴,每当钟表划过一圈,刻度重新回到零值,但是已有的时间进度会传承往下。
时间轮算法正是基于环形思想,梳理一下核心流程:
- 建立一个环状数据结构,用于模拟无限长时间
- 每个刻度对应一个时间范围
- 创建定时任务时,根据距今的相对时长,推算出需要向后推移的刻度值
- 倘若来到环形数组的结尾,则重新从起点开始计算,但是记录时把执行轮次数加
1
- 一个刻度可能存在多笔定时任务,所以每个刻度需要挂载一个定时任务链表
- 接下来,我们建立时间轮的扫描机制,就如同钟表中的指针一般,按照固定的时间节奏,沿着环形数组周而复始地持续向下扫描. 每当来到一个刻度时,则取出链表中轮次为
0
的定时任务进行执行,轮次不为0的则轮次减1
,因为时间轮运行一轮了,距离当前链表中的一个任务运行时间减少了一轮。 这就是时间轮算法的核心思路。
多级时间轮
上面一维时间,或者环形只转一轮时,可以理解为是一个一级时间轮,下面介绍一下多级时间轮的概率,实际就是环形有了轮次的概念。如手表上秒钟转一轮后,是一分钟。分针转一轮后是一小时。
那么多级时间轮有什么好处呢?
首先捋一捋,时间轮每个周期轮次中,使用的数据结构容量与所表达的时间范围之间的关系.
我们把时间轮中的每个刻度记为一个 slot
,每个 slot
表示的时间范围记为 t
.
假设时间轮中总共包含 2m
个 slot
,求问如何组织我们的时间轮数据结构,能够使得时间轮每个轮次对应表达的时间范围尽可能的长. (一个轮次对应的时间范围越长,在时间流逝过程中轮次的迭代速度就越慢,于是每个 slot
对应的定时任务链表长度就越短,执行定时任务时的检索效率就越高.)
这里最简单的方式就是进行采用一维纵向排列的方式,那么能够表达的时间范围就是 2m * t
,某个刻度对应的时间值就记为 {slot_i}
.
另一种思路是,我们在时间轮中建立一种等级秩序.
比如我们将 2m
个 slot
拆成两个等级——level1
和level2
. 最终我们通过 {level1_slot}_{level2_slot}
的方式进行时间的表达.
我们给 level2
分配m
个 slot
,其中每个 slot
对应的时间范围同样为 t
. 而 level1
同样也分配 m
个 slot
,但是此时其中每个 slot
对应的时间范围应该为 m * t
,因为在level1
中的 slot
确定时,level2
中还有 m
种 slot
的组合方式.
如此一来,这种组织方式下,时间轮单个轮次所能表达的时间范围就是 m * m * t
.
这里探讨的核心不是具体某级时间轮的时间范围结果,而是抛出了一种多级时间轮的思路,从一到二是质变,从二到三、从三到四就仅仅是量变的问题,可以继续复刻相同的思路.
回过头来看,我们会发现日常使用的时间表达式正是采用了这样一种多级时间轮的等级制度,比如当前的时刻为:2023-10-28 15:50:00
. 这本质上是一种通过 {year}-{month}-{date}-{hour}-{minute}-{second}
组成的 6
级时间轮等级结构.
二. Go实现单机版时间轮
接下来我们会使用 golang
标准库的定时器工具 time ticker
结合环状数组的设计思路,实现一个单机版的单级时间轮.
1. 核心类
时间轮类
时间轮类核心字段如下:
package main
import (
"container/list"
"sync"
"time"
)
// 单机版时间轮
type TimeWheel struct {
sync.Once // 单例工具,保证时间轮停止操作只能执行一次
interval time.Duration // 时间轮运行时间间隔
ticker *time.Ticker // 时间轮定时器
stopc chan struct{} // 停止时间轮的channel
addTaskCh chan *taskElement // 新增定时任务的入口channel
removeTaskCh chan string // 删除定时任务的入口channel
// 通过list组成的环形数组,通过遍历环形数组的方式实现时间轮
// 定时任务数量较大,每个slot槽内可能存在多个定时任务,因此通过list进行组装
slots []*list.List
curSlot int // 当前遍历到的环形数组的索引
keyToETask map[string]*list.Element //定时任务 key 到 任务节点 的映射,用于在list中删除任务节点
}
在几个核心字段中:
• slots
——类似于时钟的表盘
• curSlot
——类似于时钟的指针
• ticker
是使用 golang
标准库的定时器工具,类似于驱动指针运转的齿轮
在创建时间轮实例时,会通过一个异步的常驻 goroutine
执行定时任务的检索、添加、删除等操作,并通过几个channel
进行 goroutine
的执行逻辑和生命周期的控制:
• stopc
:用于停止 goroutine
• addTaskCh
:用于接收创建定时器指令
• removeTaskCh
:用于接收删除定时任务的指令
此处有几个技术细节需要提及:
-
首先: 所谓环状数组指的是逻辑意义上的. 在实际的实现过程中,会通过一个定长数组结合循环遍历的方式,来实现这个逻辑意义上的“环状”性质.
-
其次: 数组每一轮能表达的时间范围是固定的. 每当往时间轮添加一个定时任务时,需要根据其延迟的相对时长推算出其所处的
slot
位置,其中可能跨遍历轮次的情况,这时候需要额外通过定时任务中的cycle
字段来记录这一信息,避免定时任务被提前执行. -
最后: 时间轮中一个
slot
可能需要挂载多笔定时任务,因此针对每个slot
,需要采用golang
标准库container/list
中实现的双向链表进行定时任务数据的存储.
定时任务类
下面是对一笔定时任务的类定义:
• key
:每个定时任务的全局唯一标识键
• task
:包含了定时任务执行逻辑的闭包函数
• pos
:定时任务在环形数组所处的位置,即数组的索引 index
,也即所在的slot
• cycle
:定时任务的延迟轮次(在添加任务的时候计算出来的). 时间轮的 curSlot
指针每完成一整轮的数组遍历,所有定时任务的 cycle
指数都需要减 1
. 当定时任务 cycle
指数为 0
时,代表该任务应该在当前遍历轮次执行.
// 封装一笔定时任务的明细信息
type taskElement struct {
key string // 定时任务的唯一标识符
task func() // 内聚了定时任务执行逻辑的闭包函数
pos int // 定时任务挂载在环形数组的索引位置
// 定时任务的延迟轮次. 指的是 curSlot 指针还要扫描过环状数组多少轮,才满足执行该任务的条件
cycle int
}
2. 构造器
在创建时间轮的构造器函数中,需要传入两个入参:
• slotNum
:由使用方指定 slot
的个数,默认为 10
• interval
:由使用方指定每个 slot
对应的时间范围,默认为 1
秒
初始化时间轮实例的过程中,会完成定时器ticker
以及各个channel
的初始化,并针对数组 中的各个 slot
进行初始化,每个 slot
位置都需要填充一个 list
.
每个时间轮实例都会异步调用 run
方法,启动一个常驻 goroutine
用于接收和处理定时任务.
// 创建单机版时间轮
// slotNum : 时间轮环状数组长度
// interval:扫描时间间隔
func NewTimeWheel(slotNum int ,interval time.Duration) *TimeWheel {
// 环状数组长度默认为10
if slotNum <= 0 {
slotNum = 10
}
// 扫描时间间隔默认为1秒
if interval <= 0 {
interval = time.Second
}
// 初始化时间轮实例
t := &TimeWheel{
Once: sync.Once{},
interval: interval,
ticker: time.NewTicker(interval),
stopc: make(chan struct{}),
addTaskCh: make(chan *taskElement),
removeTaskCh: make(chan string),
slots: make([]*list.List, 0, slotNum),
curSlot: 0,
keyToETask: make(map[string]*list.Element),
}
// 初始化slots里面的每个slot,一共slotNum个
for i:= 0;i < slotNum;i++ {
t.slots = append(t.slots,list.New())
}
// 异步启动时间轮常驻goroutine
go t.run()
return t
}
3. 启动与停止
时间轮运行的核心逻辑位于timeWheel.run
方法中,该方法会通过 for
循环结合 select
多路复用的方式运行,属于 golang
中非常常见的异步编程风格.
goroutine
运行过程中需要从以下四类 channel
中接收不同的信号,并进行逻辑的分发处理:
• stopc
:停止时间轮,使得当前 goroutine
退出
• ticker
:接收到 ticker 的信号说明时间又往前推进了一个 interval
,则需要批量检索并执行对应slot
中的定时任务. 并推进指针 curSlot
往前偏移
• addTaskCh
:接收创建定时任务的指令
• removeTaskCh
:接收删除定时任务的指令
此处值得一提的是,后续不论是创建、删除还是检索定时任务,都是通过这个常驻 goroutine
完成的,因此在访问一些临界资源的时候,不需要加锁,因为不存在并发访问的情况
// 运行时间轮
func (t *TimeWheel) run() {
defer func() {
if err := recover(); err != any(nil) {
// 子协程用recover捕获panic,属于常识,避免子协程panic导致主程序退出
}
}()
// 通过for + select的代码结果运行一个常驻goroutine也是go风格代码的常规操作
for {
select {
// 停止时间轮
case <-t.stopc:
return
// 接收定时信号
case <-t.ticker.C:
t.tick() // 批量执行定时任务
// 接收创建定时任务的信号
case task := <-t.addTaskCh:
t.addTask(task)
// 接收删除定时任务的信号
case removeKey := <-t.removeTaskCh:
t.removeTask(removeKey)
}
}
}
时间轮还需要提供一个Stop
方法,用于手动停止时间轮,回收对应的goroutine
和 ticker
资源.
停止时间轮的操作是通过关闭stopc channel
完成的,由于channel
不允许被反复关闭,因此这里通过 sync.Once
保证该逻辑只被调用一次.
// 停止时间轮
func (t *TimeWheel) Stop() {
// 通过单例工具,保证channel只能被关闭一次,避免关闭已经关闭的channel出现panic
t.Do(func() {
t.ticker.Stop() // 关闭定时器ticker
close(t.stopc) // 关闭时间轮运行的stopc
})
}
4. 创建任务
创建一笔定时任务的核心步骤如下:
• 使用方往 addTaskCh
中投递定时任务,由常驻 goroutine
接收定时任务
• 根据执行时间,推算出定时任务所处的 slot
位置以及需要延迟的轮次 cycle
• 将定时任务包装成一个 list node
,追加到对应 slot
位置的list
尾部
• 以定时任务唯一键为 key
,list node
为 value
,在 keyToETask map
中建立映射关系,方便后续删除任务时使用
// 添加定时任务到时间轮中
func (t *TimeWheel) AddTask(key string, task func(), executeAt time.Time) error {
// 校验传入的时间是否小于当前时间
if executeAt.Before(time.Now()) {
return errors.New("invalid execute time")
}
// 根据执行时间推算得到定时任务从属的slot位置,以及需要延迟的轮次
pos, cycle := t.getPosAndCycle(executeAt)
// 将定时任务通过channel进行投递
t.addTaskCh <- &taskElement{
key: key,
task: task,
pos: pos,
cycle: cycle,
}
return nil
}
// 根据执行时间推算得到定时任务从属的slot位置,以及需要延迟的轮次
func (t *TimeWheel) getPosAndCycle(executeAt time.Time) (int, int) {
delay := int(time.Until(executeAt))
// 定时任务的延迟轮次
cycle := delay / (len(t.slots) * int(t.interval))
// 定时任务从属的环形数组index
pos := (t.curSlot + delay/int(t.interval)) % len(t.slots)
return pos, cycle
}
// 常驻 goroutine 接收到创建定时任务后的处理逻辑
// 注意这里两个AddTask方法,大写的为用于客户端往时间轮中添加任务,即将任务放入t.addTaskCh(channel)
// 而这里小写的addTask 则是时间轮内部for + select 监听到t.addTaskCh中可读时需要执行的操作
func (t *TimeWheel) addTask(task *taskElement) {
// 获取到定时任务从属的环形数组index 以及对应的 list
list := t.slots[task.pos]
// 倘若定时任务key之前已经存在,则需要先删除定时任务,然后再添加,即为更新操作
if _, ok := t.keyToETask[task.key]; ok {
t.removeTask(task.key)
}
// 将定时任务追加到list尾部
eTask := list.PushBack(task)
// 建立定时任务 key 到定时任务所处节点的映射
t.keyToETask[task.key] = eTask
}
5. 删除任务
删除一笔定时任务的核心步骤如下:
• 使用方往 removeTaskCh
中投递删除任务的 key
,由常驻 goroutine
接收处理
• 从 keyToETask map
中,找到该任务对应的 list node
• 从 keyToETask map
中移除该组kv
对
• 从对应 slot
的 list
中移除该 list node
// 使用方调用删除任务,往t.removeTaskCh中投递需要删除的任务的唯一key
func (t *TimeWheel) RemoveTask(key string) {
t.removeTaskCh <- key
}
// 时间轮常驻 goroutine 接收到删除任务信号后,执行的删除任务逻辑,注意是小写的removeTask
func (t *TimeWheel) removeTask(key string){
eTask ,ok := t.keyToETask[key]
if !ok { // 需要删除的任务本身就不存在
return
}
// 将定时任务节点从映射map中移除
delete(t.keyToETask,key)
// 获取到定时任务节点后,将其从list中移除
task,_:=eTask.Value.(*taskElement)
_ = t.slots[task.pos].Remove(eTask)
}
6. 执行定时任务
最后来捋一下最核心的链路——检索并批量执行定时任务的流程.
首先,每当接收到 ticker
信号时,会根据当前的 curSlot
指针,获取到对应 slot
位置挂载的定时任务 list
,调用 execute
方法执行其中的定时任务. 最后通过 circularIncr
方法推进curSlot
指针向前移动.
// 常驻 goroutine 每次接收到定时信号后用于执行定时任务的逻辑
func (t *TimeWheel) tick() {
// 根据curSlot 获取到当前所处的环状数组索引位置,取出对应的list
list := t.slots[t.curSlot]
// 在方法返回前,保证推进curSlot了指针的位置,进行环状遍历
defer t.circularIncr()
// 批量处理满足执行条件的定时任务
t.execute(list)
}
在execute
方法中,会对 list 中的定时任务进行遍历:
• 对于 cycle > 0
的定时任务,说明当前还未达到执行条件,需要将其 cycle
值减 1
,留待后续轮次再处理
• 对于cycle = 0
的定时任务,开启一个goroutine
,执行其中的闭包函数 task
,并将其从list
和 map
中移除
// 执行定时任务,每次处理一个list
func (t *TimeWheel) execute(l *list.List) {
// 遍历list
for e := l.Front(); e != nil; {
// 获取每个节点对应的定时任务信息
taskElement, _ := e.Value.(*taskElement)
// 如果任务还存在延迟轮次,则只对cycle计数器进行扣减,本轮不用执行任务
if taskElement.cycle > 0 {
taskElement.cycle--
e = e.Next()
continue
}
// 当前节点对应的定时任务已经达到执行时间,开启一个goroutine负责执行任务
go func() {
defer func() {
if err := recover(); err != any(nil) {
//...
}
}()
taskElement.task() // 执行具体的任务
}()
// 任务已经开启协程去执行了,需要把对应的任务节点从list中和map中删除
next := e.Next()
l.Remove(e)
delete(t.keyToETask, taskElement.key)
e = next // 移动到下一个链表节点,进入下一轮循环
}
}
在 circularIncr
方法中,呼应了环状数组的逻辑处理方式:
// 每次 tick 后需要推进 curSlot 指针的位置,slots 在逻辑意义上是环状数组,所以在到达尾部时需要从新回到头部
func (t *TimeWheel) circularIncr() {
t.curSlot = (t.curSlot + 1) % len(t.slots)
}
三. 完整代码
package main
import (
"container/list"
"errors"
"sync"
"time"
)
// 单机版时间轮
type TimeWheel struct {
sync.Once // 单例工具,保证时间轮停止操作只能执行一次
interval time.Duration // 时间轮运行时间间隔
ticker *time.Ticker // 时间轮定时器
stopc chan struct{} // 停止时间轮的channel
addTaskCh chan *taskElement // 新增定时任务的入口channel
removeTaskCh chan string // 删除定时任务的入口channel
// 通过list组成的环形数组,通过遍历环形数组的方式实现时间轮
// 定时任务数量较大,每个slot槽内可能存在多个定时任务,因此通过list进行组装
slots []*list.List
curSlot int // 当前遍历到的环形数组的索引
keyToETask map[string]*list.Element //定时任务 key 到 任务节点 的映射,用于在list中删除任务节点
}
// 封装一笔定时任务的明细信息
type taskElement struct {
key string // 定时任务的唯一标识符
task func() // 内聚了定时任务执行逻辑的闭包函数
pos int // 定时任务挂载在环形数组的索引位置
// 定时任务的延迟轮次. 指的是 curSlot 指针还要扫描过环状数组多少轮,才满足执行该任务的条件
cycle int
}
// 创建单机版时间轮
// slotNum : 时间轮环状数组长度
// interval:扫描时间间隔
func NewTimeWheel(slotNum int, interval time.Duration) *TimeWheel {
// 环状数组长度默认为10
if slotNum <= 0 {
slotNum = 10
}
// 扫描时间间隔默认为1秒
if interval <= 0 {
interval = time.Second
}
// 初始化时间轮实例
t := &TimeWheel{
Once: sync.Once{},
interval: interval,
ticker: time.NewTicker(interval),
stopc: make(chan struct{}),
addTaskCh: make(chan *taskElement),
removeTaskCh: make(chan string),
slots: make([]*list.List, 0, slotNum),
curSlot: 0,
keyToETask: make(map[string]*list.Element),
}
// 初始化slots里面的每个slot,一共slotNum个
for i := 0; i < slotNum; i++ {
t.slots = append(t.slots, list.New())
}
// 异步启动时间轮常驻goroutine
go t.run()
return t
}
// 运行时间轮
func (t *TimeWheel) run() {
defer func() {
if err := recover(); err != any(nil) {
// 子协程用recover捕获panic,属于常识,避免子协程panic导致主程序退出
}
}()
// 通过for + select的代码结果运行一个常驻goroutine也是go风格代码的常规操作
for {
select {
// 停止时间轮
case <-t.stopc:
return
// 接收定时信号
case <-t.ticker.C:
t.tick() // 批量执行定时任务
// 接收创建定时任务的信号
case task := <-t.addTaskCh:
t.addTask(task)
// 接收删除定时任务的信号
case removeKey := <-t.removeTaskCh:
t.removeTask(removeKey)
}
}
}
// 停止时间轮
func (t *TimeWheel) Stop() {
// 通过单例工具,保证channel只能被关闭一次,避免关闭已经关闭的channel出现panic
t.Do(func() {
t.ticker.Stop() // 关闭定时器ticker
close(t.stopc) // 关闭时间轮运行的stopc
})
}
// 添加定时任务到时间轮中
func (t *TimeWheel) AddTask(key string, task func(), executeAt time.Time) error {
// 校验传入的时间是否小于当前时间
if executeAt.Before(time.Now()) {
return errors.New("invalid execute time")
}
// 根据执行时间推算得到定时任务从属的slot位置,以及需要延迟的轮次
pos, cycle := t.getPosAndCycle(executeAt)
// 将定时任务通过channel进行投递
t.addTaskCh <- &taskElement{
key: key,
task: task,
pos: pos,
cycle: cycle,
}
return nil
}
// 根据执行时间推算得到定时任务从属的slot位置,以及需要延迟的轮次
func (t *TimeWheel) getPosAndCycle(executeAt time.Time) (int, int) {
delay := int(time.Until(executeAt))
// 定时任务的延迟轮次
cycle := delay / (len(t.slots) * int(t.interval))
// 定时任务从属的环形数组index
pos := (t.curSlot + delay/int(t.interval)) % len(t.slots)
return pos, cycle
}
// 常驻 goroutine 接收到创建定时任务后的处理逻辑
// 注意这里两个AddTask方法,大写的为用于客户端往时间轮中添加任务,即将任务放入t.addTaskCh(channel)
// 而这里小写的addTask 则是时间轮内部for + select 监听到t.addTaskCh中可读时需要执行的操作
func (t *TimeWheel) addTask(task *taskElement) {
// 获取到定时任务从属的环形数组index 以及对应的 list
list := t.slots[task.pos]
// 倘若定时任务key之前已经存在,则需要先删除定时任务,然后再添加,即为更新操作
if _, ok := t.keyToETask[task.key]; ok {
t.removeTask(task.key)
}
// 将定时任务追加到list尾部
eTask := list.PushBack(task)
// 建立定时任务 key 到定时任务所处节点的映射
t.keyToETask[task.key] = eTask
}
// 使用方调用删除任务,往t.removeTaskCh中投递需要删除的任务的唯一key
func (t *TimeWheel) RemoveTask(key string) {
t.removeTaskCh <- key
}
// 时间轮常驻 goroutine 接收到删除任务信号后,执行的删除任务逻辑,注意是小写的removeTask
func (t *TimeWheel) removeTask(key string) {
eTask, ok := t.keyToETask[key]
if !ok { // 需要删除的任务本身就不存在
return
}
// 将定时任务节点从映射map中移除
delete(t.keyToETask, key)
// 获取到定时任务节点后,将其从list中移除
task, _ := eTask.Value.(*taskElement)
_ = t.slots[task.pos].Remove(eTask)
}
// 常驻 goroutine 每次接收到定时信号后用于执行定时任务的逻辑
func (t *TimeWheel) tick() {
// 根据curSlot 获取到当前所处的环状数组索引位置,取出对应的list
list := t.slots[t.curSlot]
// 在方法返回前,保证推进curSlot了指针的位置,进行环状遍历
defer t.circularIncr()
// 批量处理满足执行条件的定时任务
t.execute(list)
}
// 执行定时任务,每次处理一个list
func (t *TimeWheel) execute(l *list.List) {
// 遍历list
for e := l.Front(); e != nil; {
// 获取每个节点对应的定时任务信息
taskElement, _ := e.Value.(*taskElement)
// 如果任务还存在延迟轮次,则只对cycle计数器进行扣减,本轮不用执行任务
if taskElement.cycle > 0 {
taskElement.cycle--
e = e.Next()
continue
}
// 当前节点对应的定时任务已经达到执行时间,开启一个goroutine负责执行任务
go func() {
defer func() {
if err := recover(); err != any(nil) {
//...
}
}()
taskElement.task() // 执行具体的任务
}()
// 任务已经开启协程去执行了,需要把对应的任务节点从list中和map中删除
next := e.Next()
l.Remove(e)
delete(t.keyToETask, taskElement.key)
e = next // 移动到下一个链表节点,进入下一轮循环
}
}
// 每次 tick 后需要推进 curSlot 指针的位置,slots 在逻辑意义上是环状数组,所以在到达尾部时需要从新回到头部
func (t *TimeWheel) circularIncr() {
t.curSlot = (t.curSlot + 1) % len(t.slots)
}
本文参考:https://mp.weixin.qq.com/s?__biz=MzkxMjQzMjA0OQ==&mid=2247484714&idx=1&sn=dc5275ca57e4607bf6373b6cfaf9b552&chksm=c10c4bf4f67bc2e2a5afe8b48f925172192f2dabe7e008628d346706e84fdbbb2750afbcd165&mpshare=1&scene=23&srcid=1021M5BxT5ya3pB9KKtDPrhJ&sharer_shareinfo=fa4d2da78b5622c07245db03c043c292&sharer_shareinfo_first=fa4d2da78b5622c07245db03c043c292#rd