1、直接上案例
import io.netty.util.HashedWheelTimer;
import io.netty.util.Timeout;
import io.netty.util.TimerTask;
import lombok.extern.log4j.Log4j2;
import java.util.concurrent.TimeUnit;
/**
* @ClassName Test
* @Author will
* @Date 2024/3/8 16:31
* @Version 1.0.1
* @Description
*/
@Log4j2
public class Test {
private static final HashedWheelTimer TIMER = new HashedWheelTimer();
private static int index = 10;
private static TimerTask createTask(int addValue) {
// 创建一个定时任务
TimerTask task = new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
index = index + addValue;
log.info("定时任务执行值: " + index);
// 如果需要重复执行,可以在这里再次添加任务
TIMER.newTimeout(this, 5, java.util.concurrent.TimeUnit.SECONDS);
}
};
return task;
}
public static void main(String[] args) {
// 创建一个定时任务
TimerTask task = createTask(2);
// 使用定时器安排任务在2秒后执行
TIMER.newTimeout(task, 2, TimeUnit.SECONDS);
// 为了看到定时器的运行,我们等待一段时间
try {
Thread.sleep(30000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 停止定时器
TIMER.stop();
}
}
2、原理说明
知识准备
需要对时间轮(Timing Wheel),以及Netty的HashedWheelTimer要解决什么问题有初步的认识。
什么是时间轮(Timing Wheel)
时间轮(Timing Wheel)是George Varghese和Tony Lauck在1996年的论文'Hashed and Hierarchical Timing Wheels: data structures to efficiently implement a timer facility'实现的,它在Linux内核中使用广泛,是Linux内核定时器的实现方法和基础之一。
时间轮(Timing Wheel)是一种环形的数据结构,就像一个时钟可以分成很多格子(Tick),每个格子代表时间的间隔,它指向存储的具体任务(timerTask)的一个链表。
以上述在论文中的图片例子,这里一个轮子包含8个格子(Tick), 每个tick是一秒钟;
任务的添加:如果一个任务要在17秒后执行,那么它需要转2轮,最终加到Tick=1位置的链表中。
任务的执行:在时钟转2Round到Tick=1的位置,开始执行这个位置指向的链表中的这个任务。(# 这里表示剩余需要转几轮再执行这个任务)
Netty的HashedWheelTimer要解决什么问题
HashedWheelTimer是Netty根据时间轮(Timing Wheel)开发的工具类,它要解决什么问题呢?这里面有两个要点:延迟任务 + 低时效性。@pdai
在Netty中的一个典型应用场景是判断某个连接是否idle,如果idle(如客户端由于网络原因导致到服务器的心跳无法送达),则服务器会主动断开连接,释放资源。判断连接是否idle是通过定时任务完成的,但是Netty可能维持数百万级别的长连接,对每个连接去定义一个定时任务是不可行的,所以如何提升I/O超时调度的效率呢?
Netty根据时间轮(Timing Wheel)开发了HashedWheelTimer工具类,用来优化I/O超时调度(本质上是延迟任务);之所以采用时间轮(Timing Wheel)的结构还有一个很重要的原因是I/O超时这种类型的任务对时效性不需要非常精准。
HashedWheelTimer的使用方式
在了解时间轮(Timing Wheel)和Netty的HashedWheelTimer要解决的问题后,我们看下HashedWheelTimer的使用方式
通过构造函数看主要参数
public HashedWheelTimer(
ThreadFactory threadFactory,
long tickDuration, TimeUnit unit, int ticksPerWheel, boolean leakDetection,
long maxPendingTimeouts, Executor taskExecutor) {
}
具体参数说明如下:
threadFactory:线程工厂,用于创建工作线程, 默认是Executors.defaultThreadFactory()
tickDuration:tick的周期,即多久tick一次
unit: tick周期的单位
ticksPerWheel:时间轮的长度,一圈下来有多少格
leakDetection:是否开启内存泄漏检测,默认是true
maxPendingTimeouts:最多执行的任务数,默认是-1,即不限制。在高并发量情况下才会设置这个参数。
Pom依赖
<dependency>
<groupId>io.netty</groupId>
<artifactId>netty-all</artifactId>
<version>4.1.77.Final</version>
</dependency>
3、dubbo使用场景
核心接口
在 Dubbo 中,所有的定时任务都要继承 TimerTask 接口。TimerTask 接口非常简单,只定义了一个 run() 方法,该方法的入参是一个 Timeout 接口的对象。Timeout 对象与 TimerTask 对象一一对应,两者的关系类似于线程池返回的 Future 对象与提交到线程池中的任务对象之间的关系。通过 Timeout 对象,我们不仅可以查看定时任务的状态,还可以操作定时任务(例如取消关联的定时任务)。Timeout 接口中的方法如下图所示:
image.png
Timer 接口定义了定时器的基本行为,如下图所示,其核心是 newTimeout() 方法:提交一个定时任务(TimerTask)并返回关联的 Timeout 对象,这有点类似于向线程池提交任务的感觉。
image
HashedWheelTimeout
HashedWheelTimeout 是 Timeout 接口的唯一实现,是 HashedWheelTimer 的内部类。HashedWheelTimeout 扮演了两个角色:
第一个,时间轮中双向链表的节点,即定时任务 TimerTask 在 HashedWheelTimer 中的容器。
第二个,定时任务 TimerTask 提交到 HashedWheelTimer 之后返回的句柄(Handle),用于在时间轮外部查看和控制定时任务。
HashedWheelTimeout 中的核心字段如下:
prev、next(HashedWheelTimeout类型),分别对应当前定时任务在链表中的前驱节点和后继节点。
task(TimerTask类型),指实际被调度的任务。
deadline(long类型),指定时任务执行的时间。这个时间是在创建 HashedWheelTimeout 时指定的,计算公式是:currentTime(创建 HashedWheelTimeout 的时间) + delay(任务延迟时间) - startTime(HashedWheelTimer 的启动时间),时间单位为纳秒。
state(volatile int类型),指定时任务当前所处状态,可选的有三个,分别是 INIT(0)、CANCELLED(1)和 EXPIRED(2)。另外,还有一个 STATE_UPDATER 字段(AtomicIntegerFieldUpdater类型)实现 state 状态变更的原子性。
remainingRounds(long类型),指当前任务剩余的时钟周期数。时间轮所能表示的时间长度是有限的,在任务到期时间与当前时刻的时间差,超过时间轮单圈能表示的时长,就出现了套圈的情况,需要该字段值表示剩余的时钟周期。
HashedWheelTimeout 中的核心方法有:
isCancelled()、isExpired() 、state() 方法, 主要用于检查当前 HashedWheelTimeout 状态。
cancel() 方法, 将当前 HashedWheelTimeout 的状态设置为 CANCELLED,并将当前 HashedWheelTimeout 添加到 cancelledTimeouts 队列中等待销毁。
expire() 方法, 当任务到期时,会调用该方法将当前 HashedWheelTimeout 设置为 EXPIRED 状态,然后调用其中的 TimerTask 的 run() 方法执行定时任务。
remove() 方法, 将当前 HashedWheelTimeout 从时间轮中删除。
HashedWheelBucket
HashedWheelBucket 是时间轮中的一个槽,时间轮中的槽实际上就是一个用于缓存和管理双向链表的容器,双向链表中的每一个节点就是一个 HashedWheelTimeout 对象,也就关联了一个 TimerTask 定时任务。
HashedWheelBucket 持有双向链表的首尾两个节点,分别是 head 和 tail 两个字段,再加上每个 HashedWheelTimeout 节点均持有前驱和后继的引用,这样就可以正向或是逆向遍历整个双向链表了。
下面我们来看 HashedWheelBucket 中的核心方法。
addTimeout() 方法:新增 HashedWheelTimeout 到双向链表的尾部。
pollTimeout() 方法:移除双向链表中的头结点,并将其返回。
remove() 方法:从双向链表中移除指定的 HashedWheelTimeout 节点。
clearTimeouts() 方法:循环调用 pollTimeout() 方法处理整个双向链表,并返回所有未超时或者未被取消的任务。
expireTimeouts() 方法:遍历双向链表中的全部 HashedWheelTimeout 节点。 在处理到期的定时任务时,会通过 remove() 方法取出,并调用其 expire() 方法执行;对于已取消的任务,通过 remove() 方法取出后直接丢弃;对于未到期的任务,会将 remainingRounds 字段(剩余时钟周期数)减一。
HashedWheelTimer
HashedWheelTimer 是 Timer 接口的实现,它通过时间轮算法实现了一个定时器。HashedWheelTimer 会根据当前时间轮指针选定对应的槽(HashedWheelBucket),从双向链表的头部开始迭代,对每个定时任务(HashedWheelTimeout)进行计算,属于当前时钟周期则取出运行,不属于则将其剩余的时钟周期数减一操作。
下面我们来看 HashedWheelTimer 的核心属性。
workerState(volatile int类型):时间轮当前所处状态,可选值有 init、started、shutdown。同时,有相应的 AtomicIntegerFieldUpdater 实现 workerState 的原子修改。
startTime(long类型):当前时间轮的启动时间,提交到该时间轮的定时任务的 deadline 字段值均以该时间戳为起点进行计算。
wheel(HashedWheelBucket[]类型):该数组就是时间轮的环形队列,每一个元素都是一个槽。当指定时间轮槽数为 n 时,实际上会取大于且最靠近 n 的 2 的幂次方值。
timeouts、cancelledTimeouts(LinkedBlockingQueue类型):timeouts 队列用于缓冲外部提交时间轮中的定时任务,cancelledTimeouts 队列用于暂存取消的定时任务。HashedWheelTimer 会在处理 HashedWheelBucket 的双向链表之前,先处理这两个队列中的数据。
tick(long类型):该字段在 HashedWheelTimer$Worker 中,是时间轮的指针,是一个步长为 1 的单调递增计数器。
mask(int类型):掩码, mask = wheel.length - 1,执行 ticks & mask 便能定位到对应的时钟槽。
ticksDuration(long类型):时间指针每次加 1 所代表的实际时间,单位为纳秒。
pendingTimeouts(AtomicLong类型):当前时间轮剩余的定时任务总数。
workerThread(Thread类型):时间轮内部真正执行定时任务的线程。
worker(Worker类型):真正执行定时任务的逻辑封装这个 Runnable 对象中。
时间轮对外提供了一个 newTimeout() 接口用于提交定时任务,在定时任务进入到 timeouts 队列之前会先调用 start() 方法启动时间轮,其中会完成下面两个关键步骤:
确定时间轮的 startTime 字段;
启动 workerThread 线程,开始执行 worker 任务。
之后根据 startTime 计算该定时任务的 deadline 字段,最后才能将定时任务封装成 HashedWheelTimeout 并添加到 timeouts 队列。
下面我们来分析时间轮指针一次转动的全流程。
时间轮指针转动,时间轮周期开始。
清理用户主动取消的定时任务,这些定时任务在用户取消时,会记录到 cancelledTimeouts 队列中。在每次指针转动的时候,时间轮都会清理该队列。
将缓存在 timeouts 队列中的定时任务转移到时间轮中对应的槽中。
根据当前指针定位对应槽,处理该槽位的双向链表中的定时任务。
检测时间轮的状态。如果时间轮处于运行状态,则循环执行上述步骤,不断执行定时任务。如果时间轮处于停止状态,则执行下面的步骤获取到未被执行的定时任务并加入 unprocessedTimeouts 队列:遍历时间轮中每个槽位,并调用 clearTimeouts() 方法;对 timeouts 队列中未被加入槽中循环调用 poll()。
最后再次清理 cancelledTimeouts 队列中用户主动取消的定时任务。
上述核心逻辑在 HashedWheelTimer$Worker.run() 方法中,若你感兴趣的话,可以翻看一下源码进行分析。
Dubbo 中如何使用定时任务
在 Dubbo 中,时间轮并不直接用于周期性操作,而是只向时间轮提交执行单次的定时任务,在上一次任务执行完成的时候,调用 newTimeout() 方法再次提交当前任务,这样就会在下个周期执行该任务。即使在任务执行过程中出现了 GC、I/O 阻塞等情况,导致任务延迟或卡住,也不会有同样的任务源源不断地提交进来,导致任务堆积。
Dubbo 中对时间轮的应用主要体现在如下两个方面:
失败重试, 例如,Provider 向注册中心进行注册失败时的重试操作,或是 Consumer 向注册中心订阅时的失败重试等。
周期性定时任务, 例如,定期发送心跳请求,请求超时的处理,或是网络连接断开后的重连机制。