31.Go实现单机版时间轮

代码地址: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.

假设时间轮中总共包含 2mslot,求问如何组织我们的时间轮数据结构,能够使得时间轮每个轮次对应表达的时间范围尽可能的长. (一个轮次对应的时间范围越长,在时间流逝过程中轮次的迭代速度就越慢,于是每个 slot 对应的定时任务链表长度就越短,执行定时任务时的检索效率就越高.)

这里最简单的方式就是进行采用一维纵向排列的方式,那么能够表达的时间范围就是 2m * t,某个刻度对应的时间值就记为 {slot_i}.

在这里插入图片描述
另一种思路是,我们在时间轮中建立一种等级秩序.

比如我们将 2mslot 拆成两个等级——level1level2. 最终我们通过 {level1_slot}_{level2_slot} 的方式进行时间的表达.

我们给 level2 分配mslot,其中每个 slot 对应的时间范围同样为 t. 而 level1 同样也分配 mslot,但是此时其中每个 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方法,用于手动停止时间轮,回收对应的goroutineticker 资源.

停止时间轮的操作是通过关闭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尾部

• 以定时任务唯一键为 keylist 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,并将其从listmap 中移除

// 执行定时任务,每次处理一个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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
this.$router.go的实现原理是通过改变浏览器的历史记录来实现路由跳转。当调用this.$router.go(n)时,n表示要向前或向后跳转的步数。如果n为正数,则向前跳转n步;如果n为负数,则向后跳转n步。具体实现原理如下: 1. 当调用this.$router.go(n)时,Vue Router会检查当前路由的历史记录长度。 2. 如果n大于0并且小于或等于历史记录长度,则通过window.history.go(n)来执行向前跳转n步。 3. 如果n小于0并且的绝对值小于或等于历史记录长度,则通过window.history.go(n)来执行向后跳转n步。 4. 如果n超过历史记录长度,则通过window.location.reload()来刷新页面,达到跳转到指定页面的效果。 总之,this.$router.go的实现原理是通过改变浏览器的历史记录来实现路由跳转。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *3* [vue this.$router.push 页面不刷新总结(8种解决方式----覆盖所有场景)](https://blog.csdn.net/qq_38143787/article/details/120920610)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *2* [vue 刷新当前页面,使用this.$router.go(0)闪白问题(使用provide / inject)](https://blog.csdn.net/a460550542/article/details/125102866)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值