掘金 后端 ( ) • 2024-06-27 17:42

定时任务在中间件和业务系统中有很多应用,比如:

  • 注册中心中定期上报状态的心跳机制。
  • RPC 框架中定期扫描请求列表移除超时请求。
  • 延迟队列提交未来时间的任务。
  • 业务系统每日凌晨跑批处理或报表任务。

Java 原生提供 Timer 和 ScheduledThreadPoolExecutor 类实现定时任务;Netty、akka、Kafka 等框架扩展时间轮算法实现定时任务。

不同的实现有着不同的用途,比如 ScheduledThreadPoolExecutor 适用于精度要求高但请求不大的情形,时间轮适用于精度要求不太高但请求量大的情况。

本文主要讨论时间轮的设计与选型,并结合 Netty 源码详细解析了 Hashed 时间轮的详细实现。

时间轮算法

在介绍算法之前,需要先明确 tick 的概念:tick 是事件触发的最小单位

时间轮算法通常由四个函数构成,分别是:

  • 供 Client 调用:
    • 开始任务:startTimer(interval, expiry_func)
    • 停止任务:stopTimer(task)
  • 当 Timer tick 触发调用:
    • 每个 tick 维护任务:perTickBookkeeping
    • 任务到期,执行任务:expiryProcessing

我们主要通过 startTimer、stopTimer 和 perTickBookkeeping 这三个函数的时间复杂度来衡量整个时间轮算法的性能,同时内存占用也是要考虑的因素之一。

在《Hashed and Hierarchical Timing Wheels: Efficient Data Structures for Implementing a Timer Facility》 论文中,介绍了几种 Timer 的设计,下表是不同的数据结构下的时间复杂度。

startTimer stopTimer perTickBookkeeping 有序线性表 O(n) O(1) O(1) 树 O(log(n)) O(1) O(1) 简单时间轮 O(1) O(1) O(1) Hashed 时间轮(sorted) 最差 O(n) 平均 O(1) O(1) O(1) Hashed 时间轮(unsorted) O(1) O(1) 最差 O(n) 平均 O(1) 带等级时间轮 O(m) m为时间轮个数 O(1) O(1)

不同的数据结构适用于不同的数据量。Netty 采用的是 Hashed 时间轮(unsorted),Kafka 采用的是带等级的时间轮。

下面我们基于 Netty 来探究它是如何高效实现时间轮算法的。

Netty 时间轮

代码示例

public static void main(String[] args) {  
    DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");    
    HashedWheelTimer timer = new HashedWheelTimer(10, TimeUnit.MILLISECONDS);    System.out.println(LocalDateTime.now().format(formatter));   
    Timeout task = timer.newTimeout(timeout -> { 
        System.out.println(LocalDateTime.now().format(formatter)); 
    }, 3, TimeUnit.SECONDS);   
    task.cancel();
}

通过 HashedWheelTimer 构造函数创建时间轮,然后通过 newTimeOut 方法提交任务,指定任务回调函数以及触发时间点,时间轮会在指定时间触发回调函数,完成定时任务逻辑。

对照前面的函数,timer.newTimeout 等同于 startTimer 操作。task.cancel 等同于 stopTimer。在 HashedWheelTimer 内部,每个 tick 触发的操作为 perTickBookkeeping,当任务时间到了时触发 expiryProcessing 操作。

数据结构

图片

上图是 HashedWheelTimer 的结构示意图,由数组和双向链表组成,和 HashMap 的结构类似,这也是被称为 HashedWheelTimer 的原因。

构造函数

HashedWheelTimer 提供了多个构造函数,这里我们主要关注 tickDuration、ticksPerWheel 这两个属性。

tickDuration 用来配置每个 tick 的间隔。值越小,时间轮的精度越高,单位时间内触发次数越多,因此性能会有一定的缩减。最小可以设置为 1ms,默认是 100ms,如无特殊情况,走默认即可。

ticksPerWheel 代表数组的长度。值越大,数据的散列程度就越好,查找效率就越高,但内存开销就会大些。值越小,内存开销小,但冲突会变多,查找效率低。当我们指定该值时,和 HashMap 一样,系统会找大于等于该值的最小的二的倍数作为数组长度,如无特殊情况,默认的 512 就可以了。

提交&取消任务

当我们调用 newTimeOut 提交任务时,系统不会直接将任务写到时间轮中,而是写入暂存队列,等待后续线程的触发。

当对任务调用 cancel 方法时,系统也不会去时间轮中删除任务,同样地写入取消任务队列,等待后续的触发。

tick 操作

当构造完毕后,会启动 worker 线程来触发 tick 操作。

如何保证 tick 按时触发

当 worker 线程启动后,会初始化当前时间为 startTime。任务的到期时间都是基于 startTime 的相对时间。

图片

这里假设每隔 tick 间隔 1000ms,此时 currentTime - startTime = 2500ms,距离下一个 tick 还有 500ms,此时通过  Thread.sleep(500) 休眠,等到唤醒时继续判断,直到相对时间大于 3000,此时触发 tick 操作。

tick 触发

tick 触发主要做三件事情:处理待取消任务、处理待提交任务和处理超时逻辑。

  • 取消任务 通过 queue 的 poll 操作拉取待取消任务,然后将任务在双向链表中移除即可。

  • 提交任务 从提交任务的 queue 中 poll 最多 10w 条数据(避免生产者持续添加任务,造成 tick 线程繁忙)然后根据任务触发时间 / tickDuration 确认任务应当在第几个 tick 被触发,将其添加到对应的数组下标的双向链表尾部。

    Math.max(calculated, tick) 是为了避免任务在提交时已经过期。如果按照 calculated 结果,很可能将任务放到 tick - 1 的位置上,这个任务只能等到时间轮再转一圈之后才能被调用到。

    long calculated = timeout.deadline / tickDuration;
    timeout.remainingRounds = (calculated - tick) / wheel.length;
    final long ticks = Math.max(calculated, tick); 
    // Ensure we don't schedule for past.int stopIndex = (int) (ticks & mask);
    HashedWheelBucket bucket = wheel[stopIndex];bucket.addTimeout(timeout);
    
  • 执行任务 遍历双向链表中的任务,如果剩余圈数小于等于 0,则执行过期逻辑,然后执行移除节点逻辑。

总结

  1. Timer 算法有很多种,Netty 使用的是 Hashed Wheel,Kafka 使用的是 Hierarchical Wheel。
  2. 时间轮适用于时间精度要求不是特别高的场景,这能够满足常见的业务场景。
  3. Netty 时间轮 startTimer 是 O(1),stopTimer 是 O(1),perTickBookkeeping 是 O(n/m),其中 n 是所有元素的数量,m 是时间轮中 tick 的个数。
  4. Netty 时间轮中任务调度和执行都是单线程的,提交的任务尽量不要包含复杂逻辑,减少时间占用。