Golang-基于TimeingWheel定时器

Python018

Golang-基于TimeingWheel定时器,第1张

在linux下实现定时器主要有如下方式

在这当中 基于时间轮方式实现的定时器 时间复杂度最小,效率最高,然而我们可以通过 优先队列 实现时间轮定时器。

优先队列的实现可以使用最大堆和最小堆,因此在队列中所有的数据都可以定义排序规则自动排序。我们直接通过队列中 pop 函数获取数据,就是我们按照自定义排序规则想要的数据。

在 Golang 中实现一个优先队列异常简单,在 container/head 包中已经帮我们封装了,实现的细节,我们只需要实现特定的接口就可以。

下面是官方提供的例子

因为优先队列底层数据结构是由二叉树构建的,所以我们可以通过数组来保存二叉树上的每一个节点

改数组需要实现 Go 预先定义的接口 Len , Less , Swap , Push , Pop 和 update 。

timerType结构是定时任务抽象结构

首先的 start 函数,当创建一个 TimeingWheel 时,通过一个 goroutine 来执行 start ,在start中for循环和select来监控不同的channel的状态

通过for循环从队列中取数据,直到该队列为空或者是遇见第一个当前时间比任务开始时间大的任务, append 到 expired 中。因为优先队列中是根据 expiration 来排序的,

所以当取到第一个定时任务未到的任务时,表示该定时任务以后的任务都未到时间。

当 getExpired 函数取出队列中要执行的任务时,当有的定时任务需要不断执行,所以就需要判断是否该定时任务需要重新放回优先队列中。 isRepeat 是通过判断任务中 interval 是否大于 0 判断,

如果大于0 则,表示永久就生效。

防止外部滥用,阻塞定时器协程,框架又一次封装了timer这个包,名为 timer_wapper 这个包,它提供了两种调用方式。

参数和上面的参数一样,只是在第三个参数中使用了任务池,将定时任务放入了任务池中。定时任务的本身执行就是一个 put 操作。

至于put以后,那就是 workers 这个包管理的了。在 worker 包中, 也就是维护了一个任务池,任务池中的任务会有序的执行,方便管理。

1.通过endless包实现

2.通过shutdown实现

在go 1.8.x后,golang在http里加入了shutdown方法,用来控制优雅退出。什么是优雅退出? 简单说就是不处理新请求,但是会处理正在进行的请求,把旧请求都处理完,也就是都response之后,那么就退出。

shutdown通过context上下文实现

社区里不少http graceful动态重启,平滑重启的库,大多是基于http.shutdown做的。平滑启动的原理很简单,fork子进程,继承listen fd, 老进程优雅退出。

3.context原理

context 是 Go 并发编程中常用到一种编程模式。

在并发程序中,由于超时、取消操作或者一些异常情况,往往需要进行抢占操作或者中断后续操作。熟悉 channel 的朋友应该都见过使用 done channel 来处理此类问题。比如以下这个例子:

上述例子中定义了一个 buffer 为0的 channel done , 子协程运行着定时任务。如果主协程需要在某个时刻发送消息通知子协程中断任务退出,那么就可以让子协程监听这个 done channel ,一旦主协程关闭 done channel ,那么子协程就可以推出了,这样就实现了主协程通知子协程的需求。这很好,但是这也是有限的。

如果我们可以在简单的通知上附加传递额外的信息来控制取消:为什么取消,或者有一个它必须要完成的最终期限,更或者有多个取消选项,我们需要根据额外的信息来判断选择执行哪个取消选项。

考虑下面这种情况:假如主协程中有多个任务1, 2, …m,主协程对这些任务有超时控制;而其中任务1又有多个子任务1, 2, …n,任务1对这些子任务也有自己的超时控制,那么这些子任务既要感知主协程的取消信号,也需要感知任务1的取消信号。

如果还是使用 done channel 的用法,我们需要定义两个 done channel ,子任务们需要同时监听这两个 done channel 。嗯,这样其实好像也还行哈。但是如果层级更深,如果这些子任务还有子任务,那么使用 done channel 的方式将会变得非常繁琐且混乱。

我们需要一种优雅的方案来实现这样一种机制:

这个时候 context 就派上用场了。

我们首先看看 context 的结构设计和实现原理。

先看 Context 接口结构,看起来非常简单。

Context 接口包含四个方法:

可以看到 Done 方法返回的 channel 正是用来传递结束信号以抢占并中断当前任务; Deadline 方法指示一段时间后当前 goroutine 是否会被取消;以及一个 Err 方法,来解释 goroutine 被取消的原因;而 Value 则用于获取特定于当前任务树的额外信息。而 context 所包含的额外信息键值对是如何存储的呢?其实可以想象一颗树,树的每个节点可能携带一组键值对,如果当前节点上无法找到 key 所对应的值,就会向上去父节点里找,直到根节点,具体后面会说到。

emptyCtx 是一个 int 类型的变量,但实现了 context 的接口。 emptyCtx 没有超时时间,不能取消,也不能存储任何额外信息,所以 emptyCtx 用来作为 context 树的根节点。

但我们一般不会直接使用 emptyCtx ,而是使用由 emptyCtx 实例化的两个变量,分别可以通过调用 Background 和 TODO 方法得到,但这两个 context 在实现上是一样的。那么 Background 和 TODO 方法得到的 context 有什么区别呢?可以看一下官方的解释:

Background 和 TODO 只是用于不同场景下:

Background 通常被用于主函数、初始化以及测试中,作为一个顶层的 context ,也就是说一般我们创建的 context 都是基于 Background ;

而 TODO 是在不确定使用什么 context 的时候才会使用。

下面将介绍两种不同功能的基础 context 类型: valueCtx 和 cancelCtx 。

valueCtx 利用一个 Context 类型的变量来表示父节点 context ,所以当前 context 继承了父 context 的所有信息; valueCtx 类型还携带一组键值对,也就是说这种 context 可以携带额外的信息。 valueCtx 实现了 Value 方法,用以在 context 链路上获取 key 对应的值,如果当前 context 上不存在需要的 key ,会沿着 context 链向上寻找 key 对应的值,直到根节点。

WithValue 用以向 context 添加键值对:

这里添加键值对不是在原 context 结构体上直接添加,而是以此 context 作为父节点,重新创建一个新的 valueCtx 子节点,将键值对添加在子节点上,由此形成一条 context 链。获取 value 的过程就是在这条 context 链上由尾部上前搜寻:

跟 valueCtx 类似, cancelCtx 中也有一个 context 变量作为父节点;变量 done 表示一个 channel ,用来表示传递关闭信号; children 表示一个 map ,存储了当前 context 节点下的子节点; err 用于存储错误信息表示任务结束的原因。

再来看一下 cancelCtx 实现的方法:

可以发现 cancelCtx 类型变量其实也是 canceler 类型,因为 cancelCtx 实现了 canceler 接口。Done 方法和 Err 方法没必要说了, cancelCtx 类型的 context 在调用 cancel 方法时会设置取消原因,将 done channel 设置为一个关闭 channel 或者关闭 channel ,然后将子节点 context 依次取消,如果有需要还会将当前节点从父节点上移除。

WithCancel 函数用来创建一个可取消的 context ,即 cancelCtx 类型的 context 。 WithCancel 返回一个 context 和一个 CancelFunc ,调用 CancelFunc 即可触发 cancel 操作。直接看源码:

之前说到 cancelCtx 取消时,会将后代节点中所有的 cancelCtx 都取消, propagateCancel 即用来建立当前节点与祖先节点这个取消关联逻辑。

这里或许有个疑问,为什么是祖先节点而不是父节点?这是因为当前 context 链可能是这样的:

当前 cancelCtx 的父节点 context 并不是一个可取消的 context ,也就没法记录 children 。

timerCtx 是一种基于 cancelCtx 的 context 类型,从字面上就能看出,这是一种可以定时取消的 context 。

timerCtx 内部使用 cancelCtx 实现取消,另外使用定时器 timer 和过期时间 deadline 实现定时取消的功能。 timerCtx 在调用 cancel 方法,会先将内部的 cancelCtx 取消,如果需要则将自己从 cancelCtx 祖先节点上移除,最后取消计时器。

WithDeadline 返回一个基于 parent 的可取消的 context ,并且其过期时间 deadline 不晚于所设置时间 d 。

与 WithDeadline 类似, WithTimeout 也是创建一个定时取消的 context ,只不过 WithDeadline 是接收一个过期时间点,而 WithTimeout 接收一个相对当前时间的过期时长 timeout :

首先使用 context 实现文章开头 done channel 的例子来示范一下如何更优雅实现协程间取消信号的同步:

这个例子中,只要让子线程监听主线程传入的 ctx ,一旦 ctx.Done() 返回空 channel ,子线程即可取消执行任务。但这个例子还无法展现 context 的传递取消信息的强大优势。

阅读过 net/http 包源码的朋友可能注意到在实现 http server 时就用到了 context , 下面简单分析一下。

1、首先 Server 在开启服务时会创建一个 valueCtx ,存储了 server 的相关信息,之后每建立一条连接就会开启一个协程,并携带此 valueCtx 。

2、建立连接之后会基于传入的 context 创建一个 valueCtx 用于存储本地地址信息,之后在此基础上又创建了一个 cancelCtx ,然后开始从当前连接中读取网络请求,每当读取到一个请求则会将该 cancelCtx 传入,用以传递取消信号。一旦连接断开,即可发送取消信号,取消所有进行中的网络请求。

3、读取到请求之后,会再次基于传入的 context 创建新的 cancelCtx ,并设置到当前请求对象 req 上,同时生成的 response 对象中 cancelCtx 保存了当前 context 取消方法。

这样处理的目的主要有以下几点:

在整个 server 处理流程中,使用了一条 context 链贯穿 Server 、 Connection 、 Request ,不仅将上游的信息共享给下游任务,同时实现了上游可发送取消信号取消所有下游任务,而下游任务自行取消不会影响上游任务。

context 主要用于父子任务之间的同步取消信号,本质上是一种协程调度的方式 。另外在使用 context 时有两点值得注意:上游任务仅仅使用 context 通知下游任务不再需要,但不会直接干涉和中断下游任务的执行,由下游任务自行决定后续的处理操作,也就是说 context 的取消操作是无侵入的; context 是线程安全的,因为 context 本身是不可变的( immutable ),因此可以放心地在多个协程中传递使用。

    func startTimer(f func()) {

        go func() {

            for {

                f()

                now := time.Now()

                // 计算下一个零点

                next := now.Add(time.Hour * 24)

                next = time.Date(next.Year(), next.Month(), next.Day(), 0, 0, 0, 0, next.Location())

                t := time.NewTimer(next.Sub(now))

                <-t.C

            }

        }()

    }