Go定时器内部的实现原理是什么

56次阅读
没有评论

共计 4264 个字符,预计需要花费 11 分钟才能阅读完成。

这篇文章主要讲解了“Go 定时器内部的实现原理是什么”,文中的讲解内容简单清晰,易于学习与理解,下面请大家跟着丸趣 TV 小编的思路慢慢深入,一起来研究和学习“Go 定时器内部的实现原理是什么”吧!

前言

本节,我们重点关注系统协程是如何管理这些定器的,包括以下问题:

定时器使用什么数据结构存储?定时器如何触发事件?定时器如何添加进系统协程?定时器如何从系统协程中删除?

定时器存储 timer 数据结构

Timer 和 Ticker 数据结构除名字外完全一样,二者都含有一个 runtimeTimer 类型的成员,这个就是系统协程所维护的对象。runtimeTimer 类型是 time 包的名称,在 runtime 包中,这个类型叫做 timer。

timer 数据结构如下所示:

type timer struct {   tb *timersBucket // the bucket the timer lives in   // 当前定时器寄存于系统 timer 堆的地址 i int // heap index                      // 当前定时器寄存于系统 timer 堆的下标 when   int64 // 当前定时器下次触发时间 period int64 // 当前定时器周期触发间隔(如果是 Timer,间隔为 0,表示不重复触发)f func(interface{}, uintptr) // 定时器触发时执行的函数 arg interface{} // 定时器触发时执行函数传递的参数一 seq    uintptr // 定时器触发时执行函数传递的参数二 ( 该参数只在网络收发场景下使用) }

其中 timersBucket 便是系统协程存储 timer 的容器,里面有个切片来存储 timer,而 i 便是 timer 所在切片的下标。

timersBucket 数据结构

我们来看一下 timersBucket 数据结构:

type timersBucket struct {lock         mutexgp           *g // 处理堆中事件的协程 created bool // 事件处理协程是否已创建,默认为 false,添加首个定时器时置为 true sleeping bool // 事件处理协程(gp)是否在睡眠 ( 如果 t 中有定时器,还未到触发的时间,那么 gp 会投入睡眠) rescheduling bool // 事件处理协程(gp)是否已暂停(如果 t 中定时器均已删除,那么 gp 会暂停)sleepUntil   int64 // 事件处理协程睡眠时间 waitnote     note // 事件处理协程睡眠事件(据此唤醒协程)t            []*timer // 定时器切片}

“Bucket”译成中文意为 桶,顾名思义,timersBucket 意为存储 timer 的容器。

lock: 互斥锁,在 timer 增加和删除时需要使用;gp: 事件处理协程,就是我们所说的系统协程,这个协程在首次创建 Timer 或 Ticker 时生成;create:状态值,表示系统协程是否创建;sleeping: 系统协程是否在睡眠;rescheduling: 系统协程是否已暂停;sleepUntil: 系统协程睡眠到指定的时间(如果有新的定时任务可能会提前唤醒);waitnote: 提前唤醒时使用的通知;t: 保存 timer 的切片,当调用 NewTimer() 或 NewTicker() 时便会有新的 timer 存到此切片中;

看到这里应该能明白,系统协程在首次创建定时器时创建,定时器存储在切片中,系统协程负责计时并维护这个切片。

存储拓扑

以 Ticker 为例,我们回顾一下 Ticker、timer 和 timersBucket 关系,假设我们已经创建了 3 个 Ticker,那么它们之间的关系如下:

用户创建 Ticker 时会生成一个 timer,这个 timer 指向 timersBucket,timersBucket 记录 timer 的指针。

timersBucket 数组

通过 timersBucket 数据结构可以看到,系统协程负责计时并维护其中的多个 timer,一个 timersBucket 包含一个系统协程。

当系统中定时器非常多时,一个系统协程可能处理能力跟不上,所以 Go 在实现时实际上提供了多个 timersBucket,也就有多个系统协程来处理定时器。

最理想的情况,应该预留 GOMAXPROCS 个 timersBucket,以便充分使用 CPU 资源,但需要跟据实际环境动态分配。为了实现简单,Go 在实现时预留了 64 个 timersBucket,绝大部分场景下这些足够了。

每当协程创建定时器时,使用协程所属的 ProcessID%64 来计算定时器存入的 timersBucket。

下图三个协程创建定时器时,定时器分布如下图所示:

为描述方便,上图中 3 个协程均分布于 3 个 Process 中。

一般情况下,同一个 Process 的协程创建的定时器分布于同一个 timersBucket 中,只有当 GOMAXPROCS 大于 64 时才会出现多个 Process 分布于同一个 timersBucket 中。

定时器运行机制

看完上面的数据结构,了解了 timer 是如何存储的。现在开始探究定时器内部运作机制。

创建定时器

回顾一下定时器创建过程,创建 Timer 或 Ticker 实际上分为两步:

创建一个管道创建一个 timer 并启动(注意此 timer 不是 Timer,而是系统协程所管理的 timer。)

创建管道的部分前面已做过介绍,这里我们重点关注 timer 的启动部分。

首先,每个 timer 都必须要归属于某个 timersBucket 的,所以第一步是先选择一个 timersBucket,选择的算法很简单,将当前协程所属的 Processor ID 与 timersBucket 数组长度求模,结果就是 timersBucket 数组的下标。

const timersLen = 64 var timers [timersLen]struct {// timersBucket 数组,长度为 64 timersBucket}func (t *timer) assignBucket() *timersBucket { id := uint8(getg().m.p.ptr().id) % timersLen // Processor ID 与数组长度求模,得到下标 t.tb = timers[id].timersBucket return t.tb}

至此,第一步,给当前的 timer 选择一个 timersBucket 已经完成。

其次,每个 timer 都必须要加入到 timersBucket 中。前面我们知道,timersBucket 中切片中保存着 timer 的指针,新加入的 timer 并不是按加入时间顺序存储的,而是把 timer 按照触发的时间排序的一个小头堆。那么 timer 加入 timersBucket 的过程实际上也是堆排序的过程,只不过这个排序是指的是新加元素后的堆调整过程。

源码 src/runtime/time.go:addtimerLocked() 函数负责添加 timer:

func (tb *timersBucket) addtimerLocked(t *timer) bool {if t.when 0 {t.when = 1 63 – 1}t.i = len(tb.t) // 先把定时器插入到堆尾 tb.t = append(tb.t, t) // 保存定时器 if !siftupTimer(tb.t, t.i) {// 堆中插入数据,触发堆重新排序 return false} if t.i == 0 {// 堆排序后,发现新插入的定时器跑到了栈顶,需要唤醒协程来处理 // siftup moved to top: new earliest deadline. if tb.sleeping { // 协程在睡眠,唤醒协程来处理新加入的定时器 tb.sleeping = false notewakeup( tb.waitnote)} if tb.rescheduling {// 协程已暂停,唤醒协程来处理新加入的定时器 tb.rescheduling = false goready(tb.gp, 0)}} if !tb.created {// 如果是系统首个定时器,则启动协程处理堆中的定时器 tb.created = true go timerproc(tb)}return true }

跟据注释来理解上面的代码比较简单,这里附加几点说明:

如果 timer 的时间是负值,那么会被修改为很大的值,来保证后续定时算法的正确性;系统协程是在首次添加 timer 时创建的,并不是一直存在;新加入 timer 后,如果新的 timer 跑到了栈顶,意味着新的 timer 需要立即处理,那么会唤醒系统协程。

下图展示一个小顶堆结构,图中每个圆圈代表一个 timer,圆圈中的数字代表距离触发事件的秒数,圆圈外的数字代表其在切片中的下标。其中 timer 15 是新加入的,加入后它被最终调整到数组的 1 号下标。

上图展示的是二叉堆,实际上 Go 实现时使用的是四叉堆,使用四叉堆的好处是堆的高度降低,堆调整时更快。

删除定时器

当 Timer 执行结束或 Ticker 调用 Stop() 时会触发定时器的删除。从 timersBucket 中删除定时器是添加定时器的逆过程,即堆中元素删除后,触发堆调整。在此不再细述。

timerproc

timerproc 为系统协程的具体实现。它是在首次创建定时器创建并启动的,一旦启动永不销毁。如果 timersBucket 中有定时器,取出堆顶定时器,计算睡眠时间,然后进入睡眠,醒来后触发事件。

某个 timer 的事件触发后,跟据其是否是周期性定时器来决定将其删除还是修改时间后重新加入堆。

如果堆中已没有事件需要触发,则系统协程将进入暂停态,也可认为是无限时睡眠,直到有新的 timer 加入才会被唤醒。

timerproc 处理事件的流程图如下:

资源泄露问题

前面介绍 Ticker 时格外提醒不使用的 Ticker 需要显式的 Stop(),否则会产生资源泄露。研究过 timer 实现机制后,可以很好的解释这个问题了。

首先,创建 Ticker 的协程并不负责计时,只负责从 Ticker 的管道中获取事件;其次,系统协程只负责定时器计时,向管道中发送事件,并不关心上层协程如何处理事件;

如果创建了 Ticker,则系统协程将持续监控该 Ticker 的 timer,定期触发事件。如果 Ticker 不再使用且没有 Stop(),那么系统协程负担会越来越重,最终将消耗大量的 CPU 资源。

感谢各位的阅读,以上就是“Go 定时器内部的实现原理是什么”的内容了,经过本文的学习后,相信大家对 Go 定时器内部的实现原理是什么这一问题有了更深刻的体会,具体使用情况还需要大家实践验证。这里是丸趣 TV,丸趣 TV 小编将为大家推送更多相关知识点的文章,欢迎关注!

正文完
 
丸趣
版权声明:本站原创文章,由 丸趣 2023-08-03发表,共计4264字。
转载说明:除特殊说明外本站除技术相关以外文章皆由网络搜集发布,转载请注明出处。
评论(没有评论)