在Redisson分布式锁的实现一文中,我们说到Redisson会调用scheduleExpirationRenewal
方法创建一个定时任务来刷新锁的过期时间,防止任务执行完毕前锁就过期释放了。在那篇文章中,我们没有详述这个定时任务的原理,本文中我们来探究一番定时任务的原理。
定时任务的本质
一个Timer本质上是这样的一个数据结构:deadline越近的任务拥有越高优先级,提供以下3中基本操作:
schedule
新增cancel
删除expire
执行到期的任务updateDeadline
更行到期时间(可选)
expire
通常有两种工作方式:
轮询
每隔一个时间片就去查找哪些任务已经到期
睡眠/唤醒
- 不停地查找deadline最近的任务,如到期则执行;否则sleep直到其到期
- 在sleep期间,如果有任务被
cancel
或schedule
,则deadline最近的任务有可能改变,线程会被唤醒并重新进行1的逻辑
具体实现的数据结构可以有很多选择:(假设任务持有自己在总体任务集合中对应的节点,cancel
时不需要查找的过程)
有序链表
schedule
:O(n)cancel
:O(1) //双向链表的节点删除expire
:O(1) //不停地查看第一个就可以了
堆heap
schedule
:O(log2N) //调整heapcancel
:O(log2N) //调整heapexpire
:O(1)
HashedWheelTimer
Redisson
使用的定时任务是Netty
提供的HashedWheelTimer
。
Hash Wheel Timer
是一个环形结构,可以想象成时钟,分为很多格子,一个格子代表一段时间(越短Timer精度越高),并用一个List保存在该格子上到期的所有任务。同时一个指针随着时间流逝一格一格转动,并执行对应List中所有到期的任务。任务通过取模决定应该放入哪个格子。
以上图为例,假设一个格子是1秒,则整个wheel能表示的时间段为8s,假设当前指针指向2,此时需要调度一个3s后执行的任务,显然应该加入到(2+3=5)的方格中,指针再走3次就可以执行了;如果任务要在10s后执行,应该等指针走完一个round零2格再执行,因此应放入4,同时将round(1)保存到任务中。检查到期任务应当只执行round为0的,格子上其他任务的round应减1.
schedule
:O(1)cancel
:O(1)expire
:最坏情况O(n),平均O(1) //显然格子越多每个格子对应的List就越短,越接近O(1);最坏情况下所有的任务都在一个格子中,O(n)
使用
HashedWheelTimer
的使用如下所示:
1 |
|
执行结果如下:
1 | 14:16:50.743 [main] INFO love.wangqi.timer.TimerTest - start |
从日志的打印时间,我们看到程序运行的大概3秒钟之后,我们定义的TimerTask
开始执行。
我们知道,HashedWheelTimer
以一个固定的时间间隔前进一个格子,并且激活对应格子里面的任务,但是这里有个缺陷,就是任务是串行的,也就是所有的任务是依次执行,如果调度的任务耗时比较长的话,容易出现调度超时的情况,因此很可能产生任务堆集的情况出现。
以如下代码所示:
1 |
|
执行结果如下:
1 | 14:24:02.872 [main] INFO love.wangqi.timer.TimerTest - start |
我们的本意其实是程序执行后延时1秒,然后两个任务同时开始执行。但实际的执行结果显示task2
被task1
阻塞了,直到task1
执行结束task2
才开始执行。这就导致了串行化的任务调度延时,因此,应该避免耗时比较长的任务在同一个线程中执行。
源码解读
关键的成员变量
1 | // 指针转动以及任务执行的线程 |
构造方法
1 | public HashedWheelTimer( |
添加定时任务
在前面的实例中,我们看到使用newTimeout
方法来添加定时任务。newTimeout
方法的代码如下:
1 |
|
这里使用的Queue不是普通Java自带的Queue的实现,而是使用JCTool
——一个高性能的并发Queue实现包。
启动时间轮
1 | // 这个方法不需要用户显式地调用。因为在添加定时任务(newTimeout方法)的时候会自动调用此方法 |
AtomicIntegerFieldUpdater
是JUC里面的类,原理是利用安全的反射进行原子操作,来获取实例的本身的属性。有比AtomicInteger
更好的性能和更低的内存占用。
停止时间轮
1 |
|
HashedWheelTimeout
newTimeout
方法中会将我们的TimerTask
包装成一个HashedWheelTimeout
对象,然后添加到Queue<HashedWheelTimeout> timeouts
队列中。
HashedWheelTimeout
是一个定时任务的内部包装类,双向链表结构。保存定时任务到期执行的任务、deadline、round等信息。
1 | private static final class HashedWheelTimeout implements Timeout { |
HashedWheelBucket
HashedWheelBucket
用来存放HashedWheelTimeout
,结构类似于LinkedList
。提供了expireTimeouts
方法来执行格子中的过期任务
1 | private static final class HashedWheelBucket { |
Worker
前面我们看到时间轮启动时启动Worker
线程。Worker
是时间轮的核心线程类。tick的转动,过期任务的处理都是在这个线程中处理的。
1 | private final class Worker implements Runnable { |
总结
总体来说,HashedWheelTimer
使用的是一个比较朴素的算法,要点有两个:
添加定时任务
- 如果worker线程没有执行则启动worker线程。
- 将定时任务task包装成
HashedWheelTimeout
,然后添加到Queue<HashedWheelTimeout> timeouts
队列中
worker线程的执行
- 调用
waitForNextTick
方法等待直到下一个tick - 调用
processCancelledTasks
方法处理被取消的任务。从Queue<HashedWheelTimeout> cancelledTimeouts
队列(调用cancel
方法取消任务时会将任务添加到该队列中)中取出被取消的任务,然后将其从格子的任务列表中移除。 - 计算当前tick所在的格子(
bucket
) - 调用
transferTimeoutsToBuckets
方法将timeouts
队列中新建的任务转移到所在格子的链表中 - 调用
HashedWheelBucket.expireTimeouts
方法执行到期的任务
- 调用
这里有几个值的注意的数据结构:
- 任务并不是直接放在格子中的,而是维护了一个双向链表,这种数据结构非常便于插入和移除。
- 新添加的任务并不直接放入格子,而是先放入一个队列中,这是为了避免多线程插入任务的冲突。在每个tick运行任务之前由worker线程自动对任务进行归集和分类,插入到对应的槽位里面。
http://novoland.github.io/并发/2014/07/26/定时器(Timer)的实现.html
https://zhuanlan.zhihu.com/p/32906730