定时器设计:传统方法和时间轮算法

组成

触发“时针”pointer转动的Ticker:一般使用定时循环(一般使用死循环,循环中根据需要进行适当的休眠)
存放定时任务的数据结构(一般是hash表)

工作过程

当Ticker触发时,pointer进行自增,在每一次自增中都会对pointer对应的槽中任务链表进行扫描,执行并删除过期的任务,直至pointer的时间与当前时间一致。

核心问题

  • 如何设计高效数据结构以适应不同的定时任务?
  • Ticker的设计
  • 线程安全性和并发控制策略

下面通过分析一些应用来理解这种设计(对时间轮和普通定时器的设计进行了分析)。

应用

FastDFS
哈希表的设计

数据结构:数组 + 链表
在这里插入图片描述其中slot通过数组实现,一个slot代表一个刻度,“时针”通过求余的方法将每一次tick哈希到slot中。slot存在一个指针指向task循环链表的一个结点,这个结点作为遍历链表的起点;新添加的结点会被添加到slot指向的结点前面,slot指针也会指向新添加的结点。

Ticker的设计

FastDFS的使用时间轮实现超时控制以及定时任务,通过在某个线程中不断循环进行实现的。每次tick会检查槽中的任务列表,取出列表中所有过期的任务进行执行。

并发控制

非线程安全

Kafka

Kafka的定时器设计为层级时间轮(但是我觉得设计一般呐!我觉得Linux内核的设计更符合层级时间轮的设计),在学习Kafka的层级时间轮之前需要先了解几个参数。

参数含义
unit单位时间,即tick一次的时间增加的长度,单位ms
wheelSize时间轮的大小,即一个时间轮slot的数量
interval能表示的最大时间间隔, i n t e r v a l = w h e e l S i z e ∗ u n i t interval = wheelSize * unit interval=wheelSizeunit
currentTime时间轮当前时间,这个时间为unit的整数倍

Note:时间轮的currentTime一般总是小于等于当前时间。

在这里插入图片描述Note:每个槽里的区间表示落到该槽的绝对时间的范围。

槽(slot)采用一个固定数组,每个槽的任务列表通过一个循环链表进行存储。一个时间轮包含wheelSize个槽(slot),当加入一个过期时间为expiration的定时任务时,判断为未过期的任务会通过 ( e p i r a t i o n T i m e / u n i t )   m o d   w h e e l S i z e (epirationTime/unit)\bmod wheelSize (epirationTime/unit)modwheelSize计算出槽位,并将其插入任务列表的尾部。

在这里插入图片描述时间轮里有一个overflowTimewheel变量,用于指向下一级的时间轮。当某个定时任务的过期时间expiration超过该时间轮所能表示的范围时,则会为该时间轮增加一级时间轮,并使overflowTimewheel指向它,并尝试往该时间轮添加该过期任务。

每一级的时间轮的单位时间(unit)、时间轮大小(wheelSize)、当前时间(currentTime)需要保持一致。第n级时间轮的所能表示的最大时间间隔通过 w h e e l S i z e ∗ m a x I n t e r v a l n − 1 wheelSize*maxInterval_{n-1} wheelSizemaxIntervaln1计算得出。

在这里插入图片描述
时间轮的的时间tick是通过延迟队列的poll(long timeout)方法,该方法会不断检查优先级队列中的的队头元素的延迟时间,如果延迟时间小于等于0,则返回该元素。该方法的作用相当于休眠min(timeout, 队头元素的延迟时间)。

当往Timer中添加定时任务时,首先需要判断加入的定时任务是否以及过期,如果以及过期则直接提交至执行线程,否则会将任务插入到时间轮中,并将Bucket添加到延迟队列中。延迟队列里的元素是通过优先级队列进行存储的——本质上是一个小根堆,队头都是延迟时间最小的元素,所以循环poll方法取出来的元素都是按照延迟时间的自然顺序排列。

并发控制

使用synchronized关键字

性能分析

下面根据论文中的方式分析一下各种操作的性能:
假设m为时间轮的大小,n为定时任务的数量,k为时间轮的级数
1、START_TIMER
往时间轮插入定时任务其实就是哈希表的插入操作,时间复杂度为 O ( 1 ) O(1) O(1);而由于每一个bucket需要插入至延时队列中,小根堆的插入复杂度为 O ( log ⁡ 2 n ) O(\log_2n) O(log2n),那么k级时间轮在最差的情况下的插入时间复杂度为 O ( log ⁡ 2 k m ) O(\log_2km) O(log2km)

2、STOP_TIMER
不支持

3、PER_TICK
TICK其实都是只是需要从延迟队列获取队首元素,而延迟队列获取队首元素的时间复杂度为的时间复杂度的 O ( log ⁡ 2 k m ) O(\log_2km) O(log2km)

4、EXPIRY_PROCESSING
由于处理过期任务要么就是重新将定时任务插入到bucket中,要么就是将过期任务提交至处理线程池,这两种操作的时间复杂度均为为 O ( 1 ) O(1) O(1)

为什么不直接使用DelayedQueue?
假设定时任务的数量为n,使用DelayedQueue下分析四种操作的时间复杂度:
1、START_TIME
DelayedQueue的插入时间复杂度为 O ( log ⁡ 2 n ) O(\log_2n) O(log2n)

2、STOP_TIMER
删除时间复杂度 O ( log ⁡ 2 n ) O(\log_2n) O(log2n)

3、PER_TICK
取队首元素 O ( log ⁡ 2 n ) O(\log_2n) O(log2n)

4、EXPIRY_PROCESSING
同时间轮一致,为 O ( 1 ) O(1) O(1)

为什么使用DelayedQueue而不使用Thread.sleep(long)?

Netty

在这里插入图片描述
Netty的时间轮数据结构设计与FastDFS的差不多,但是有一处值得注意:为什么Netty先使用一个队列来进行存储定时任务,而不直接加入到时间轮中?

Netty对时间轮的操作是没有进行加锁或者同步操作的,主要是靠一个无锁并发队列:MpscUnboundedArrayQueue、MpscUnboundedAtomicArrayQueue进行并发控制,Mpsc是指Multi-Producer-Single-Consumer。然后让单线程Worker Thread从Queue中获取数据并添加上时间轮中,这有点像使用MQ处理高并发的逻辑。

Linux内核定时器

内核源码版本:linux-2.5.17
源码位置:/kernel/timer.c

数据结构

定长Hash表 + 双向循环链表

在这里插入图片描述

寻位方法

expires表示从距离过期的时间长度

  1. 选择层级
    当expires:
    [ − , 2 8 ) [-, 2^8) [,28)区间时,定时任务落在第1层;
    [ 2 8 , 2 14 ) [2^8, 2^{14}) [28,214)区间时,定时任务落在第2层;
    [ 2 14 , 2 20 ) [2^{14}, 2^{20}) [214,220)区间时,定时任务落在第3层;
    [ 2 20 , 2 26 ) [2^{20}, 2^{26}) [220,226)区间时,定时任务落在第4层;
    [ 2 26 , 2 64 ) [2^{26}, 2^{64}) [226,264)区间时,定时任务落在第5层;

  2. hash方法
    当expires:
    [ − , 2 8 ) [-, 2^8) [,28)区间时, i n d e x = e x p i r e s m o d    256 index = expires\mod256 index=expiresmod256;
    [ 2 8 , 2 14 ) [2^8, 2^{14}) [28,214)区间时, i n d e x = ( e x p i r e s / 2 8 ) m o d    64 index = (expires/2^8) \mod 64 index=(expires/28)mod64;
    [ 2 14 , 2 20 ) [2^{14}, 2^{20}) [214,220)区间时, i n d e x = ( e x p i r e s / 2 14 ) m o d    64 index = (expires/2^{14}) \mod 64 index=(expires/214)mod64;
    [ 2 20 , 2 26 ) [2^{20}, 2^{26}) [220,226)区间时, i n d e x = ( e x p i r e s / 2 20 ) m o d    64 index = (expires/2^{20}) \mod 64 index=(expires/220)mod64;
    [ 2 26 , 2 64 ) [2^{26}, 2^{64}) [226,264)区间时, i n d e x = ( e x p i r e s / 2 26 ) m o d    64 index = (expires/2^{26}) \mod 64 index=(expires/226)mod64;
    注:在Linux源代码中全部使用高效位运算代替

  3. 在链表中的位置
    表尾

并发控制方法

使用Linux spinlock

Ticker

在这里插入图片描述这一层相当于时钟的秒针,只是时间单位为单位是节拍,转动一圈为256节拍。该层级每次tick都会检查目前槽里定时任务的过期时间,如果过期,则将其从列表移除并执行。
在这里插入图片描述当第一层的tick为0时,这种情况意味着第一层级相当于时钟的秒针转动了一圈,这时,第二层级相当时钟的分针向前移动一个单位,同时还会重插移动前的槽位中的所有定时任务。
在这里插入图片描述如果在上面情况下,第二层级tick之后值为1,表明第二层级(分针)也转动了一圈,
这时第三层级(时针)同样会向前移动一个单位,并重插移动前槽位的所有定时任务。其他层级以此类推。

任务重插可能会出现两种情况:
1、当前层级重插。这种情况下该任务的expire_duration还在当前层级的区间内
2、降一层级重插。这种情况下该任务的expire_duration已经退出当前层级区间,落到低一层级的区间

因为时间一直往前移,expire_duration一直会减少,所以每一个任务终究有降一级重插的时候,在执行前都会落到第一层级,等候第一层级的tick到达所在槽位。

Mark:但在源码中未找到初始化index=0的代码,C在这种情况下值应该是不确定的状态,有点奇怪。

ScheduledThreadExecutorService

在这里插入图片描述

数据结构

DelayedQueue + 直接使用ThreadExecutorPool

Ticker

多线程进行从延迟队列中take任务控制循环,执行任务之后将其添加回延迟队列

并发控制

AQS

Timer

在这里插入图片描述

数据结构

使用一个TimerThread + 优先级队列

Ticker

tongguo通过LoopThread不断从优先级队列中取出执行绝对时间最小的任务,首先需要判断任务是否过期,如果未过期,通过Object#wait(timeout)方法休眠至可执行时机,或者更小时间的任务加入;如果已过期,周期任务需要重新计算下一次执行时间,然后线程执行任务。

并发控制

优先级队列并发控制:通过使用Object#wait和Object#notifyAll进行来控制循环
任务并发控制:synchronized

普通定时器和时间轮的性能分析

定时器的性能和精准度主要体现在Tick执行任务的效率,对于使用延迟队列和优先级队列的普通定时器,每次pop队首任务时,都需要执行重建堆的操作,时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n);而时间轮一般都是采用hash表的数据结构,而且hash方法通过简单求模的方法,一般时间复杂度为 O ( 1 ) O(1) O(1)

总结

在定时器的设计中,使用的数据结构主要有:优先级队列、延迟队列、时间轮哈希表;
避免忙等待的方法:通过timeout、通过sleep、通过Java的Object#wait(timeout)和Object#notify
并发控制的方式:spinlock(CAS、Linux spinlock)、管程(synchronized)、锁(lock)、高并发数据结构串行化数据(Mpsc)

©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页