周其仁:停止改革,我们将面临三大麻烦

抛开立场观点不谈,且看周小平写一句话能犯多少语病

罗马尼亚的声明:小事件隐藏着大趋势——黑暗中的风:坚持做对的事相信未来的结果

布林肯突访乌克兰,为何选择去吃麦当劳?

中国不再是美国第一大进口国,贸易战殃及纺织业? 美国进一步延长352项中国商品的关税豁免期

生成图片,分享到微信朋友圈

自由微信安卓APP发布,立即下载! | 提交文章网址
查看原文

定时器的实现

yanger 冰岩作坊 2022-06-10

1 定时器是什么

在我们做开发的过程中,很多场景会用到定时器,例如:

  1. 使用 TCP 长连接时,客户端需要定时向服务端发送心跳请求。
  2. 每个月底重置用户的使用量为0。
  3. 某个活动日的 0 点,定时开启活动开关。

定时器非常常见,普遍存在于各个场景中,一般定时任务的有这样几种形式:

  • 经过固定时间后触发;
  • 按照固定频率周期性触发;
  • 在某个时刻触发。

定时器是什么?我们可以把它理解为这样一个数据结构:

存储一系列的任务集合,并且 Deadline 越接近的任务,拥有越高的执行优先级。
对于用户来说,支持如下操作:
NewTask:将新任务加入任务集合
Cancel:取消某个任务
对于调度来说,还需要支持:
Run:执行一个到期的定时任务

一般来说,采用轮询的方式判断任务是否到期,即 每隔一个时间片 去检查 最近的任务 是否到期,并且,在 NewTaskCancel 的行为发生之后,任务调度策略也会出现调整。

从底层原理来说,定时器还是靠线程轮询实现的。

2 各种数据结构实现

接下来,给出几种数据结构,我们可以衡量一下 NewTask(新增任务),Cancel(取消任务),Run(执行到期的定时任务)这三个指标,分析它们使用不同数据结构的时间复杂度。

2.1 双向有序链表

NewTask:O(N)
Cancel:O(1)
Run:O(1)
N:任务数

NewTask 时间复杂度是 O(N),需要按照 expireTime 在双向链表中找到合适的位置;
Cancel 时间复杂度是O(1),任务在 Cancel 时,会持有自己节点的引用,所以不需要遍历查找其在链表中所在的位置,借助双向链表的特点我们可以直接删除该节点;
Run 时间复杂度是 O(1),由于整个双向链表是基于 expireTime 有序的,所以调度器只需要轮询第一个任务即可。

2.2 堆

NewTask:O(logN)
Cancel:O(logN)
Run:O(1)
N:任务数

基于expireTime大小来构造和调整堆。
NewTaskO(logN)和CancelO(logN)分别对应堆插入和删除元素的时间复杂度;RunO(1),由 expireTime 形成的小根堆,堆顶的任务就是最快的即将过期的任务。

堆与双向有序链表相比, NewTaskCancel 时间复杂对各有优劣,但是在现实场景中,定时任务取消的情况并不是很多,所以基于堆实现的定时器要比双向有序链表表现更优秀。

2.3 时间轮

除了基于双向链表和堆的实现外,Netty中实现了 HashedWheelTimer 时间轮算法。
HashedWheelTimer 是一个环形结构,可以用时钟来类比,钟面上有很多 bucket ,每一个 bucket 上可以存放多个任务,使用一个 链表保存该时刻到期的所有任务,同时有一个指针随着时间流逝一格一格转动,并执行对应 bucket 上所有到期的任务。任务通过 取模 后的值决定应该放入哪个 bucket ,使用链表来解决 Hash 冲突。

时间轮算法

以上图为例,假设一个 bucket 是 1 秒,则指针转动一轮表示的时间段为 8s,假设当前指针指向 0,此时需要调度一个 3s 后执行的任务,显然应该加入到 (0+3=3) 的方格中,指针再走 3 次就可以执行了;如果任务要在 10s 后执行,应该等指针走完一轮零 2 格再执行,因此应放入 2,同时将 round(1)保存到任务中。检查到期任务时只执行 round 为 0 的, bucket 上其他任务的 round 减 1。

再看图中的 bucket5,我们可以知道在 后,有两个任务需要执行,在 后有一个任务需要执行。

NewTask:O(1)
Cancel:O(1)
Run:O(M), 最差O(N)
Tick:O(1)
M:bucket上的任务数 ,M ~ N/C ,其中 C 为单轮 bucket 数,Netty 中默认为 512

当多个任务分配到同一个 bucket 时,会导致M很大。加入了Tick操作,多了一个转动指针的开销。

PS. 每个bucket中的定时器是以(双向)链表形式存储的,每次添加的时候直接插入到链表的开始(头插法)。由于使用头插法,因此在运行到某个bucket时,需要遍历一遍链表,以检查是否有到达时间的计时器,有的话就运行,并删除结点。没有的话该结点round-1。

传统定时器是面向任务的,而时间轮定时器是面向 bucket 的。

HashedWheelTimer有两个重要的参数:tickDurationticksPerWheel

  1. tickDuration:一个 bucket 代表的时间,也就是指针转动一格的时间,默认为 100ms;
  2. ticksPerWheel:一轮含有多少个 bucket ,默认为 512 个,如果任务较多可以增大这个参数,降低任务分配到同一个 bucket 的概率。

2.4 层级时间轮

Kafka 针对时间轮算法进行了优化,实现了层级时间轮 TimingWheel

如果任务的时间跨度很大,数量也多,传统的 HashedWheelTimer 会造成任务的 round 很大,单个 bucket 的任务 链表 很长,并会维持很长一段时间。这时可将轮盘按时间粒度分级:

层级时间轮

现在,每个任务除了要维护在当前轮盘的 round,还要计算在所有下级轮盘的 round。当本层的 round 为 0 时,任务按下级 round 值被下放到下级轮子,最终在最底层的轮盘得到执行。

NewTask:O(H)
Cancel:O(H) Run:O(M)
Tick:O(1)
H:层级数量

举个例子,比如一个定时了 3天10小时50分30秒 的定时任务,在 tickDuration = 1s 的单层时间轮中,需要经过: 次指针的拨动才能被执行。但在 wheel1 tickDuration = 1 天,wheel2 tickDuration = 1 小时,wheel3 tickDuration = 1 分,wheel4 tickDuration = 1 秒 的四层时间轮中,只需要经过 次指针的拨动!

相比单层时间轮,层级时间轮在时间跨度较大时存在明显的优势。

2.5 时间轮的不足

虽然时间轮的实现看起来已经比较完美,但实际上它也有一些不足:

  1. 处理遍历链表是单线程,执行下一个task需要等待上一个task结束,可能会导致执行时间延后。而采用线程池会占用大量系统资源,因此时间轮不适合处理耗时较长的任务。
  2. 时间轮的精度可能不是很高,对于精度要求特别高的调度任务可能不太适合。因为时间轮算法的精度取决于tickDuration,时间段“指针”单元的最小粒度大小,比如时间轮的格子是一秒跳一次,那么调度精度小于一秒的任务就无法被时间轮所调度。
  3. 在插入新的task时,会有tickDuration/2 的误差。expireTime由到期时间和当前时间的差值求得,精度为tickDuration,因为可能在一个tickDuration的任意阶段插入,所以误差的期望为tickDuration/2.

3 总结

以上就是定时器常用的几种实现方式,大体的思路都是一致的,就是轮询离当前时间最近的任务是否到期,到期就执行。除了双向链表、最小堆、时间轮的实现外,还有基于红黑树、四叉堆等很多种类的实现,有兴趣的读者可以深入研究一下。

 

注:图文部分来自互联网

文章有问题?点此查看未经处理的缓存