一、时间轮介绍
之前公司内部搭建的延迟队列服务有用到时间轮,但是一直没有了解过它的实现原理。
最近有个和支付宝对接的项目,支付宝接口有流量控制,一定的时间内只允许 N 次接口调用,针对一些业务我们需要频繁调用支付宝开放平台接口,如果不对请求做限制,很容易触发流控告警。
为了避免这个问题,我们按照一定延迟规则将任务加载进时间轮内,通过时间轮的调度来实现接口异步调用。
很多开源框架都实现了时间轮算法,这里以 Netty 为例,看下 Netty 中时间轮是怎么实现的。
1.1 快速入门
下面是一个 API 使用例子。
1 | java复制代码public class WheelTimerSamples { |
上面的例子中我们自定义了一个 HashedWheelTimer
,然后自定义了一个 TimerTask
,将一个任务加载进时间轮,3s 后执行这个任务,怎么样是不是很简单。
在定义时间轮时建议按照业务类型进行区分,将时间轮定义为多个单例对象。
PS:因为时间轮是异步执行的,在任务执行之前 JVM 不能退出,所以 System.in.read();
这一行代码不能删除。
1.2 原理图解
二、原理分析
2.1 时间轮状态
时间轮有以下三种状态:
- WORKER_STATE_INIT:初始化状态,此时时间轮内的工作线程还没有开启
- WORKER_STATE_STARTED:运行状态,时间轮内的工作线程已经开启
- WORKER_STATE_SHUTDOWN:终止状态,时间轮停止工作
状态转换如下,转换原理会在下面讲到:
2.2 构造函数
1 | java复制代码 public HashedWheelTimer( |
构造函数中的参数相当重要,当自定义时间轮时,我们应该根据业务的范围设置合理的参数:
- threadFactory:创建时间轮任务线程的工厂,通过这个工厂可以给我们的线程自定义一些属性(线程名、异常处理等)
- tickDuration:时钟多长时间拨动一次,值越小,时间轮精度越高
- unit:
tickDuration
的单位 - ticksPerWheel:时间轮数组大小
- leakDetection:是否检测内存泄漏
- maxPendingTimeouts:时间轮内最大等待的任务数
时间轮的时钟拨动时长应该根据业务设置恰当的值,如果设置的过大,可能导致任务触发时间不准确。如果设置的过小,时间轮转动频繁,任务少的情况下加载不到任务,属于一直空转的状态,会占用 CPU 线程资源。
为了防止时间轮占用过多的 CPU 资源,当创建的时间轮对象大于 64 时会以日志的方式提示。
构造函数中只是初始化了轮线程,并没有开启,当第一次往时间轮内添加任务时,线程才会开启。
2.3 往时间轮内添加任务
1 | java复制代码 @Override |
任务会先保存在队列中,当时间轮的时钟拨动时才会判断是否将队列中的任务加载进时间轮。
1 | java复制代码 public void start() { |
这里通过 CAS 加锁的方式保证线程安全,避免多次开启。
工作线程开启后,start()
方法会被阻塞,等工作线程的 startTime
属性初始化完成后才被唤醒。为什么只有等 startTime
初始化后才能继续执行呢?因为上面的 newTimeout
方法在线程开启后,需要计算当前添加进来任务的执行时间,而这个执行时间是根据 startTime
计算的。
2.4 时间轮调度
1 | java复制代码 @Override |
时间轮每拨动一次 tick
就会 +1,根据这个值与(时间轮数组长度 - 1)进行 &
运算,可以定位时间轮数组内的槽。因为 tick
值一直在增加,所以时间轮数组看起来就像一个不断循环的圆。
- 先初始化
startTime
值,因为后面任务执行的时间是根据startTime
计算的 - 时钟拨动,如果时间未到,则
sleep
一会儿 - 处理过期的任务
- 将任务加载进时间轮
- 执行当前时钟对应时间轮内的任务
- 时间轮关闭,将所有未执行的任务封装到
unprocessedTimeouts
集合中,在stop
方法中返回出去 - 处理过期的任务
上面简单罗列了下 run
方法的大概执行步骤,下面是具体方法的分析。
2.5 时钟拨动
如果时间轮设置的 tickDuration
为 100ms 拨动一次,当时钟拨动一次后,应该计算下一次时钟拨动的时间,如果还没到就 sleep
一会儿,等到拨动时间再醒来。
1 | java复制代码 private long waitForNextTick() { |
如果时间不到就 sleep
等待一会儿,为了使任务时钟准确,可以从上面的代码中看出 Netty 做了一些优化,比如 sleepTimeMs
的计算,Windows 平台的处理等。
2.6 将任务从队列加载进时间轮
1 | java复制代码 private void transferTimeoutsToBuckets() { |
在上面也提到过,任务刚加进来不会立即到时间轮中去,而是暂时保存到一个队列中,当时间轮时钟拨动时,会将任务从队列中加载进时间轮内。
时间轮每次最大处理 100000 个任务,因为任务的执行时间是用户自定义的,所以需要计算任务到执行需要经过多少次时钟拨动,并计算时间轮拨动的圈数。接着将任务加载进时间轮对应的槽内,可能有多个任务经过 hash 计算后定位到同一个槽,这些任务会以双向链表的结构保存,有点类似 HashMap
处理碰撞的情况。
2.7 执行任务
1 | java复制代码 public void expireTimeouts(long deadline) { |
时间轮槽内的任务以链表形式存储,这些任务执行的时间可能会不一样,有的在当前时钟执行,有的在下一圈或者下两圈对应的时钟执行。当任务在当前时钟执行时,需要将这个任务从链表中删除,重新维护链表关系。
2.8 终止时间轮
1 | java复制代码 @Override |
当终止时间轮时,时间轮状态有两种情况:
- WORKER_STATE_INIT:时间轮初始化,前面我们说过,当初始化时间轮对象时并不会立即开启时间轮工作线程,而是第一次添加任务时才开启,为
WORKER_STATE_INIT
表示时间轮没有处理过任务 WORKER_STATE_STARTED
:时间轮在工作,这里也有两种情况,存在并发与不存在并发,如果多个线程都尝试终止时间轮,肯定只能有一个成功
时间轮停止运行后会将未执行的任务返回出去,至于怎么处理这些任务,由业务方自己定义,这个流程和线程池的 shutdownNow
方法是类似的。
如果时间轮在运行,怎么才能获取到未执行的任务呢,答案就在上面的 run()
方法中,如果时间轮处于非运行状态,会把时间轮数组与队列中未执行且未取消的任务保存到 unprocessedTimeouts
集合中。而终止时间轮成功的线程只需要等待一会儿即可,这个等待是通过 workerThread.join(100);
实现的。
取消时间轮内的任务相对比较简单,这里就不概述了,想要了解的自行查看即可。
上面就是时间轮运行的基本原理了。
三、总结
这里以问答的形式进行总结,大家也可以看下这些问题,自己能不能很好的回答出来?
3.1 时间轮是不是在初始化完成后就启动了?
不是,初始化完成时间轮的状态是 WORKER_STATE_INIT
,此时时间轮内的工作线程还没有运行,只有第一次往时间轮内添加任务时,才会开启时间轮内的工作线程。时间轮线程开启后会初始化 startTime
,任务的执行时间会根据这个字段计算,而且时间轮中时间的概念是相对的。
3.2 如果时间轮内还有任务未执行,服务重启了怎么办?
时间轮内的任务都在内存中,服务重启数据肯定都丢了,所以当服务重启时需要业务方自己做兼容处理。
3.3 如何自定义合适的时间轮参数?
自定义时间轮时有两个比较重要的参数需要我们注意:
- tickDuration:时钟拨动频率,假设一个任务在 10s 后执行,
tickDuration
设置为 3min 那肯定是不行的,tickDuration
值越小,任务触发的精度越高,但是没有任务时,工作线程会一直自旋尝试从队列中拿任务,比较消耗 CPU 资源 - ticksPerWheel:时间轮数组大小,假设当时间轮时钟拨动时,有 10000 个任务处理,但是我们定义时间轮数组的大小为 8,这时平均一个时间轮槽内有 1250 个任务,如果这 1250 个任务都在当前时钟执行,任务执行是同步的,由于每个任务执行都会消耗时间,可能会导致后面的任务触发时间不准确。反之如果数组长度设置的过大,任务比较少的情况下,时间轮数组很多槽都是空的
所以当使用自定义时间轮时,一定要评估自己的业务后再设置参数。
3.4 Netty 的时间轮有什么缺陷?
Netty 中的时间轮是通过单线程实现的,如果在执行任务的过程中出现阻塞,会影响后面任务执行。除此之外,Netty 中的时间轮并不适合创建延迟时间跨度很大的任务,比如往时间轮内丢成百上千个任务并设置 10 天后执行,这样可能会导致链表过长 round
值很大,而且这些任务在执行之前会一直占用内存。
3.5 时间轮要设置成单例的吗?
强烈建议按照业务模块区分,每个模块都创建一个单例的时间轮对象。在上面的代码中我们看到了,当时间轮对象大于 64 时会以日志的形式提示。如果时间轮是非单例对象,那时间轮算法完全就失去了作用。
3.6 时间轮与 ScheduledExecutorService 的区别?
ScheduledExecutorService
中的任务维护了一个堆,当有大量任务时,需要调整堆结构导致性能下降,而时间轮通过时钟调度,可以不受任务量的限制。
当任务量比较少时时间轮会一直自旋空转拨动时钟,相比 ScheduledExecutorService
会占用一定 CPU 资源。
参考
netty源码解读之时间轮算法实现-HashedWheelTimer
本文转载自: 掘金