定时任务基础知识
- 按固定周期定时执行
- 延迟一定时间后执行
- 指定某个时刻执行
JDK 提供了三种常用的定时器实现方式
- Timer:可实现固定周期的任务,以及延迟任务。
- 缺点:单线程模式,某个 Task 执行时间过长会影响其他任务的调度。Timer 的任务调度是基于系统绝对时间,系统时间不正确则会出现未知问题。TimerTask 执行出现异常,Timer 并不会捕获,会导致线程终止,其他任务永不执行。
- DelayedQueue:延迟获取对象的阻塞队列,内部采用优先队列 ProrityQueue 存储对象。
- 实现重试机制:接口调用失败或请求超时后可以将当前请求对象放入 DelayQueue,通过一个异步线程 take() 取出对象然后继续进行重试。如果请求失败,继续放回 DelayQueue。为了限制重试的频率,可以设置重试的最大次数以及采用指数退避算法设置对象的 deadline,如2S、4S、8S…
- ScheduledThreadPoolExecutor:
时间轮算法思想来源于钟表,分成多个 slot 槽位,
最大优势:
- 任务的新增和取消都是
时间复杂度
- 只需要一个线程就可以驱动时间轮进行工作
Netty 的 HashedWheelTimer
Netty 的时间轮使用固定的时间间隔 tickDuration
推进时间,关于时间轮的执行流程会分为两个大步骤:
- 任务添加。通过调用
io.netty.util.HashedWheelTimer#newTimeout
方法添加定时任务,而这个任务将会被io.netty.util.HashedWheelTimer.HashedWheelTimeout
对象包装后添加到MPSC
队列中。 - 任务处理。Netty 会单独启用一个新的工作线程处理定时任务。处理步骤简单描述如下:
- 计算下次 tick 的截止时间。并使线程休眠到下一个 tick 执行时间。
- 获取当前 tick 在
HashedWheelBucket
数组中对应的下标。 - 移除被取消的任务。
- 从
MPSC
队列中取出任务(最多取出 1000 个任务)加入到对应的 slot 中。 - 执行到期的任务。
tick++
,时针走到下一个 tick。
工作线程任务处理核心方法还是在 do...while
循环中,但是当时间轮退出后,对时间轮中的已添加到 slot 但未处理的任务需要加入未处理任务列表,方便 stop()
方法返回。
Netty 的时间轮实现优缺点:
- 时间简洁高效,源码通俗易懂。时间轮的设计思想大致能体现。
- 但是由于使用固定间隔
tickDuration
推进时间,在时间粒度跨度大的场景下会造成空推的情况,比如只有两个任务,A 在 1S 后执行,B 在 6 小时后执行,工作线程启动,执行完 1S 后, 还会继续以 tickDuration 间隔时间不断轮询,即便此时没有任务需要被执行。在一定程序上造成性能损耗。
Kafka 内部也有时间轮,它是 Netty 的进阶版,可以让时间轮的使用场景更加广泛。它的解决思路如下:
- 同 Netty 一样,也是使用环形数组存储定时任务,每个 slot 代表一个 Bucket,每个 Bucket 保存定时任务列表 TimerTaskList,TimerTaskList 同样采用双向链表的结构实现,链表上的每个结点代表真正需要被处理的任务 TimerTaskEntry。
- Kafka 使用 JDK 提供的 DelayQueue 解决 Netty 的空推问题。具体思路是:
- DelayQueue 保存时间轮中的每个 Bucket,根据 Bucket 的到期时间进行排序。这是一个小顶堆实现,Bucket 到期时间最短的被放在堆顶。
- Kafka 使用一个线程读取 DelayQueue 中的任务列表,如果时间未到,则 DelayQueue 会一直处于阻塞状态,这一步可以解决空推问题。
- Kafka 引入层级时间轮解决任务时间跨度大的难题。当任务的截止时间超出当前所在的时间轮表示范围时,就会尝试将任务添加到上一层时间轮中,类似时钟的时、分、秒转动规则。