定时器的几种实现方式

1 前言

在开始正题以前,先闲聊几句。有人说,计算机科学这个学科,软件方向研究到头就是数学,硬件方向研究到头就是物理,最轻松的是中间这批使用者,能够不太懂物理,不太懂数学,依旧可使用计算机做为本身谋生的工具。这个规律具备普适应,看看“定时器”这个例子,往应用层研究,有 Quartz,Spring Schedule 等框架;往分布式研究,又有 SchedulerX,ElasticJob 等分布式任务调度;往底层实现看,又有多种定时器实现方案的原理、工做效率、数据结构能够深究…简单上手使用一个框架,并不能体现出我的的水平,如何与他人构成区分度?我以为至少要在某一个方向有所建树:html

  1. 深刻研究某个现有框架的实现原理,例如:读源码
  2. 将一个传统技术在分布式领域很好地延伸,不少成熟的传统技术可能在单机 work well,但分布式场景须要不少额外的考虑。
  3. 站在设计者的角度,若是从零开始设计一个轮子,怎么利用合适的算法、数据结构,去实现它。

回到这篇文章的主题,我首先会围绕第三个话题讨论:设计实现一个定时器,可使用什么算法,采用什么数据结构。接着再聊聊第一个话题:探讨一些优秀的定时器实现方案。java

2 理解定时器

不少场景会用到定时器,例如git

  1. 使用 TCP 长链接时,客户端须要定时向服务端发送心跳请求。
  2. 财务系统每月的月末定时生成对帐单。
  3. 双 11 的 0 点,定时开启秒杀开关。

定时器像水和空气通常,广泛存在于各个场景中,通常定时任务的形式表现为:通过固定时间后触发、按照固定频率周期性触发、在某个时刻触发。定时器是什么?能够理解为这样一个数据结构:github

存储一系列的任务集合,而且 Deadline 越接近的任务,拥有越高的执行优先级 在用户视角支持如下几种操做: NewTask:将新任务加入任务集合 Cancel:取消某个任务 在任务调度的视角还要支持: Run:执行一个到期的定时任务算法

判断一个任务是否到期,基本会采用轮询的方式,每隔一个时间片 去检查 最近的任务 是否到期,而且,在 NewTask 和 Cancel 的行为发生以后,任务调度策略也会出现调整。数组

说到底,定时器仍是靠线程轮询实现的。微信

3 数据结构

咱们主要衡量 NewTask(新增任务),Cancel(取消任务),Run(执行到期的定时任务)这三个指标,分析他们使用不一样数据结构的时间/空间复杂度。数据结构

3.1 双向有序链表

在 Java 中,LinkedList 是一个自然的双向链表并发

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

NewTask O(N) 很容易理解,按照 expireTime 查找合适的位置便可;Cancel O(1) ,任务在 Cancel 时,会持有本身节点的引用,因此不须要查找其在链表中所在的位置,便可实现当前节点的删除,这也是为何咱们使用双向链表而不是普通链表的缘由是 ;Run O(1),因为整个双向链表是基于 expireTime 有序的,因此调度器只须要轮询第一个任务便可。

3.2 堆

在 Java 中,PriorityQueue 是一个自然的堆,能够利用传入的 Comparator 来决定其中元素的优先级。

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

expireTime 是 Comparator 的对比参数。NewTask O(logN) 和 Cancel O(logN) 分别对应堆插入和删除元素的时间复杂度 ;Run O(1),由 expireTime 造成的小根堆,咱们总能在堆顶找到最快的即将过时的任务。

堆与双向有序链表相比,NewTask 和 Cancel 造成了 trade off,但考虑到现实中,定时任务取消的场景并非不少,因此堆实现的定时器要比双向有序链表优秀。

3.3 时间轮

Netty 针对 I/O 超时调度的场景进行了优化,实现了 HashedWheelTimer 时间轮算法。

时间轮算法

HashedWheelTimer 是一个环形结构,能够用时钟来类比,钟面上有不少 bucket ,每个 bucket 上能够存放多个任务,使用一个 List 保存该时刻到期的全部任务,同时一个指针随着时间流逝一格一格转动,并执行对应 bucket 上全部到期的任务。任务经过取模决定应该放入哪一个 bucket 。和 HashMap 的原理相似,newTask 对应 put,使用 List 来解决 Hash 冲突。

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

再看图中的 bucket5,咱们能够知道在 1*8+5=13s 后,有两个任务须要执行,在 2*8+5=21s 后有一个任务须要执行。

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

时间轮算法的复杂度可能表达有误,比较难算,仅供参考。另外,其复杂度还受到多个任务分配到同一个 bucket 的影响。而且多了一个转动指针的开销。

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

构造 Netty 的 HashedWheelTimer 时有两个重要的参数:tickDurationticksPerWheel

  1. tickDuration:即一个 bucket 表明的时间,默认为 100ms,Netty 认为大多数场景下不须要修改这个参数;
  2. ticksPerWheel:一轮含有多少个 bucket ,默认为 512 个,若是任务较多能够增大这个参数,下降任务分配到同一个 bucket 的几率。

3.4 层级时间轮

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

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

层级时间轮

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

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

设想一下一个定时了 3 天,10 小时,50 分,30 秒的定时任务,在 tickDuration = 1s 的单层时间轮中,须要通过:3*24*60*60+10*60*60+50*60+30 次指针的拨动才能被执行。但在 wheel1 tickDuration = 1 天,wheel2 tickDuration = 1 小时,wheel3 tickDuration = 1 分,wheel4 tickDuration = 1 秒 的四层时间轮中,只须要通过 3+10+50+30 次指针的拨动!

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

4 常见实现

4.1 Timer

JDK 中的 Timer 是很是早期的实现,在如今看来,它并非一个好的设计。

// 运行一个一秒后执行的定时任务
Timer timer = new Timer();
timer.schedule(new TimerTask() {
    @Override
    public void run() {
        // do sth
    }
}, 1000);
复制代码

使用 Timer 实现任务调度的核心是 TimerTimerTask。其中 Timer 负责设定 TimerTask 的起始与间隔执行时间。使用者只须要建立一个 TimerTask 的继承类,实现本身的 run 方法,而后将其丢给 Timer 去执行便可。

public class Timer {
    private final TaskQueue queue = new TaskQueue();
    private final TimerThread thread = new TimerThread(queue);
}
复制代码

其中 TaskQueue 是使用数组实现的一个简易的堆。另一个值得注意的属性是 TimerThreadTimer 使用惟一的线程负责轮询并执行任务。Timer 的优势在于简单易用,但也由于全部任务都是由同一个线程来调度,所以整个过程是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到以后的任务。

轮询时若是发现 currentTime < heapFirst.executionTime,能够 wait(executionTime - currentTime) 来减小没必要要的轮询时间。这是广泛被使用的一个优化。

  1. Timer 只能被单线程调度
  2. TimerTask 中出现的异常会影响到 Timer 的执行。

因为这两个缺陷,JDK 1.5 支持了新的定时器方案 ScheduledExecutorService

4.2 ScheduledExecutorService

// 运行一个一秒后执行的定时任务
ScheduledExecutorService service = Executors.newScheduledThreadPool(10);
service.scheduleA(new Runnable() {
    @Override
    public void run() {
        //do sth
    }
}, 1, TimeUnit.SECONDS);
复制代码

相比 TimerScheduledExecutorService 解决了同一个定时器调度多个任务的阻塞问题,而且任务异常不会中断 ScheduledExecutorService

ScheduledExecutorService 提供了两种经常使用的周期调度方法 ScheduleAtFixedRate 和 ScheduleWithFixedDelay。

ScheduleAtFixedRate 每次执行时间为上一次任务开始起向后推一个时间间隔,即每次执行时间为 : initialDelay, initialDelay+period, initialDelay+2*period, …

ScheduleWithFixedDelay 每次执行时间为上一次任务结束起向后推一个时间间隔,即每次执行时间为:initialDelay, initialDelay+executeTime+delay, initialDelay+2*executeTime+2*delay, ...

因而可知,ScheduleAtFixedRate 是基于固定时间间隔进行任务调度,ScheduleWithFixedDelay 取决于每次任务执行的时间长短,是基于不固定时间间隔的任务调度。

ScheduledExecutorService 底层使用的数据结构为 PriorityQueue,任务调度方式较为常规,不作特别介绍。

4.3 HashedWheelTimer

Timer timer = new HashedWheelTimer();
//等价于 Timer timer = new HashedWheelTimer(100, TimeUnit.MILLISECONDS, 512);
timer.newTimeout(new TimerTask() {
    @Override
    public void run(Timeout timeout) throws Exception {
        //do sth
    }
}, 1, TimeUnit.SECONDS);
复制代码

前面已经介绍过了 Netty 中 HashedWheelTimer 内部的数据结构,默认构造器会配置轮询周期为 100ms,bucket 数量为 512。其使用方法和 JDK 的 Timer 十分类似。

private final Worker worker = new Worker();// Runnable
private final Thread workerThread;// Thread
复制代码

因为篇幅限制,我并不打算作详细的源码分析,但上述两行来自 HashedWheelTimer 的代码阐释了一个事实:HashedWheelTimer 内部也一样是使用单个线程进行任务调度。与 JDK 的 Timer 同样,存在”前一个任务执行时间过长,影响后续定时任务执行“的问题。

理解 HashedWheelTimer 中的 ticksPerWheel,tickDuration,对两者进行合理的配置,可使得用户在合适的场景获得最佳的性能。

5 最佳实践

5.1 选择合适的定时器

毋庸置疑,JDK 的 Timer 使用的场景是最窄的,彻底能够被后二者取代。如何在 ScheduledExecutorServiceHashedWheelTimer 之间如何作选择,须要区分场景,作一个简单的对比:

  1. ScheduledExecutorService 是面向任务的,当任务数很是大时,使用堆(PriorityQueue)维护任务的新增、删除会致使性能降低,而 HashedWheelTimer 面向 bucket,设置合理的 ticksPerWheel,tickDuration ,能够不受任务量的限制。因此在任务很是多时,HashedWheelTimer 能够表现出它的优点。
  2. 相反,若是任务量少,HashedWheelTimer 内部的 Worker 线程依旧会不停的拨动指针,虽然不是特别消耗性能,但至少不能说:HashedWheelTimer 必定比 ScheduledExecutorService 优秀。
  3. HashedWheelTimer 因为开辟了一个 bucket 数组,占用的内存会稍大。

上述的对比,让咱们获得了一个最佳实践:在任务很是多时,使用 HashedWheelTimer 能够得到性能的提高。例如服务治理框架中的心跳定时任务,服务实例很是多时,每个客户端都须要定时发送心跳,每个服务端都须要定时检测链接状态,这是一个很是适合使用 HashedWheelTimer 的场景。

5.2 单线程与业务线程池

咱们须要注意HashedWheelTimer 使用单线程来调度任务,若是任务比较耗时,应当设置一个业务线程池,将HashedWheelTimer 当作一个定时触发器,任务的实际执行,交给业务线程池。

若是全部的任务都知足: taskNStartTime - taskN-1StartTime > taskN-1CostTime,即任意两个任务的间隔时间小于先执行任务的执行时间,则无需担忧这个问题。

5.3 全局定时器

实际使用 HashedWheelTimer 时,应当将其当作一个全局的任务调度器,例如设计成 static 。时刻谨记一点:HashedWheelTimer 对应一个线程,若是每次实例化 HashedWheelTimer,首先是线程会不少,其次是时间轮算法将会彻底失去意义。

5.4 为 HashedWheelTimer 设置合理的参数

ticksPerWheel,tickDuration 这两个参数尤其重要,ticksPerWheel 控制了时间轮中 bucket 的数量,决定了冲突发生的几率,tickDuration 决定了指针拨动的频率,一方面会影响定时的精度,一方面决定 CPU 的消耗量。当任务数量很是大时,考虑增大 ticksPerWheel;当时间精度要求不高时,能够适当加大 tickDuration,不过大多数状况下,不须要 care 这个参数。

5.5 何时使用层级时间轮

当时间跨度很大时,提高单层时间轮的 tickDuration 能够减小空转次数,但会致使时间精度变低,层级时间轮既能够避免精度下降,又避免了指针空转的次数。若是有时间跨度较长的定时任务,则能够交给层级时间轮去调度。此外,也能够按照定时精度实例化多个不一样做用的单层时间轮,dayHashedWheelTimer、hourHashedWheelTimer、minHashedWheelTimer,配置不一样的 tickDuration,此法虽 low,但不失为一个解决方案。Netty 设计的 HashedWheelTimer 是专门用来优化 I/O 调度的,场景较为局限,因此并无实现层级时间轮;而在 Kafka 中定时器的适用范围则较广,因此其实现了层级时间轮,以应对更为复杂的场景。

6 参考资料

[1] www.ibm.com/developerwo…

[2] novoland.github.io/并发/2014/07/…

[3] www.cs.columbia.edu/~nahum/w699…

欢迎关注个人微信公众号:「Kirito的技术分享」,关于文章的任何疑问都会获得回复,带来更多 Java 相关的技术分享。

关注微信公众号
相关文章
相关标签/搜索