在上一篇文章中, 我们对标准库中的定时器相关功能进行了熟悉, 并且简单介绍了一种高性能的层级时间轮. 在本篇文章中, 将开始讲解如何参考 Kafka Purgatory 去实现一个简单的层级时间轮.
整体结构
一个层级时间轮的实现中, 主要包括三个部分: 优先级队列, 延时队列以及时间轮自身. 它们三者的关系如下:
其中:
- 一个层级时间轮中有多层的时间轮, 每个轮盘上有多个插槽, 每个插槽都包含有相应时间范围内的定时器
- 一个层级时间轮中有一个延时队列, 如果一个插槽中包含有定时器, 则该插槽的触发时间会被添加到延时队列, 并在该时间到期时从队列中弹出
- 延时队列会包含有多个触发时间, 所有的触发时间都通过优先级队列进行排序
当一个定时任务被放置到层级时间轮之中时, 流程如下:
- 按照任务的触发时间添加到某一层的时间轮的插槽中, 如果时间轮不存在则创建.
- 如果插槽的触发时间发生了变化, 则把对应的插槽添加到延时队列
- 延时队列在等待对应的触发时间之后, 将改元素弹出
- 层级时间轮收到插槽弹出的时间之后, 遍历该插槽中的定时任务, 如果到达触发时间则触发改任务, 否则重新插入到层级时间轮
优先级队列
优先级队列是基于最小堆的实现, 最小堆是一种经过排序的二叉树结构, 它的特点是其中任何一个非终端节点的数据值均不会大于其左子节点和右子节点的数据值. 这样可以很容易的证明, 其中最小的值一定在根节点元素.
基于最小堆我们可以很方便的实现优先级队列, 只需要将优先级作为节点的数据值即可.
Priority Queue
在标准库的 container/heap 包中, 实现了多个用于辅助最小堆实现的函数, 利用它们, 一个非常简易的优先级队列实现如下:
// Element for priority queue element
- 将所有元素放置在一个切片中, 模拟一个二叉树的结构
- 将元素添加到切片之后, 调用 heap.Push 来完成最小堆结构的调整
- 将元素从切片中弹出或者移除元素之后, 调用 heap.Pop 或者 heap.Remove 来完成最小堆结构的调整
延时队列
在有一个优先级队列的实现之后, 可以在这个结构之上快速的实现一个延时队列, 延时队列的接口定义如下:
// DelayQueue is an blocking queue of *Delay* elements, the element
其中最重要的是 Offer 以及 Poll 两个函数, 以及 Chan 函数返回的通道. 我们可以通过 Offer 将延时元素放入队列, 并且通过 Poll 函数来启动延时队列, 最后从 Chan 函数返回的通道中读取触发的元素.
接下来我们看看 Offer 函数的实现:
// Offer implement the DelayQueue.Offer
首先将元素添加到优先级队列中(根据触发时间进行排序), 并且如果是队列中的最优先的元素(具有最小的触发时间), 则通过一个内部的通道唤醒等待中的 goroutine.
接下来看 Poll 函数的实现(具体内容在 pollImpl 函数中):
// Poll implement the DelayQueue.Pool
整个 Poll 函数是一个无限循环, 只有当 ctx 被取消时候才会退出. 在循环中主要有以下几部分的逻辑:
- 从优先级队列中取出队列头部的元素, 如果队列中元素为空则睡眠在内部通道上, 等待被 Offer 函数唤醒
- 如果该元素已经到达触发时间, 则发送到返回通道中, 并且从优先级队列中移除该元素, 表示该元素已经触发
- 如果该元素还未到达触发时间, 则通过 time.After 等待对应的超时时间, 然后进入下一轮循环; 或者被 Offer 中添加的新元素唤醒, 进入下一轮循环.
总结结构
在本篇文章中我们从层级时间轮的整体结构, 以及优先级队列和延时队列的简单实现中进行了一番探索. 在后续文章中会对层级时间轮自身如何实现进行进一步梳理.
参考资料
- Hashed and Hierarchical Timing Wheels, 层级时间轮
- Kafka Purgatory
- DelayQueue