概要
时间轮是一种非常惊艳的数据结构。其在Linux内核中使用广泛,是Linux内核定时器的实现方法和基础之一。Netty内部基于时间轮实现了一个HashedWheelTimer来优化I/O超时的检测,本文将详细分析HashedWheelTimer的使用及原理。
背景
由于Netty动辄管理100w+的连接,每一个连接都会有很多超时任务。比如发送超时、心跳检测间隔等,如果每一个定时任务都启动一个Timer,不仅低效,而且会消耗大量的资源。
在Netty中的一个典型应用场景是判断某个连接是否idle,如果idle(如客户端由于网络原因导致到服务器的心跳无法送达),则服务器会主动断开连接,释放资源。得益于Netty NIO的优异性能,基于Netty开发的服务器可以维持大量的长连接,单台8核16G的云主机可以同时维持几十万长连接,及时掐掉不活跃的连接就显得尤其重要。
看看官方文档说明:
A optimized for approximated I/O timeout scheduling.
You can increase or decrease the accuracy of the execution timing by
- specifying smaller or larger tick duration in the constructor. In most
- network applications, I/O timeout does not need to be accurate. Therefore,
- the default tick duration is 100 milliseconds and you will not need to try
- different configurations in most cases.
大概意思是一种对“适当”I/O超时调度的优化。因为I/O timeout这种任务对时效性不需要准确。
这种方案也不是Netty凭空造出来的,而是根据George Varghese和Tony Lauck在1996年的论文实现的,有兴趣的可以阅读一下。
论文下载
论文PPT
应用场景
HashedWheelTimer本质是一种类似延迟任务队列的实现,那么它的特点就是上述所说的,适用于对时效性不高的,可快速执行的,大量这样的“小”任务,能够做到高性能,低消耗。
例如:
- 心跳检测
- session、请求是否timeout
业务场景则有: - 用户下单后发短信
- 下单之后15分钟,如果用户不付款就自动取消订单
简单使用
如果之前没用过,先看看用法有一个大体的感受,
1 | 4j |
需要引入netty-all.jar
包
使用上跟ScheduledExecutorService
差不多。
实现原理
源码基于netty-all.4.1.34.Final
数据结构
时间轮其实就是一种环形的数据结构,可以想象成时钟,分成很多格子,一个格子代码一段时间(这个时间越短,Timer的精度越高)。并用一个链表报错在该格子上的到期任务,同时一个指针随着时间一格一格转动,并执行相应格子中的到期任务。任务通过取摸决定放入那个格子。如下图所示:
假设一个格子是1秒,则整个wheel能表示的时间段为8s,假如当前指针指向2,此时需要调度一个3s后执行的任务,显然应该加入到(2+3=5)的方格中,指针再走3次就可以执行了;如果任务要在10s后执行,应该等指针走完一个round零2格再执行,因此应放入4,同时将round(1)保存到任务中。检查到期任务时应当只执行round为0的,格子上其他任务的round应减1。
再回头看看构造方法的三个参数分别代表
- tickDuration
每一tick的时间 - timeUnit
tickDuration的时间单位 - ticksPerWheel
就是轮子一共有多个格子,即要多少个tick才能走完这个wheel一圈。
对于HashedWheelTimer的数据结构在介绍完源码之后有图解。
初始化
HashedWheelTimer整体代码不难,慢慢看应该都可以看懂
我们从HashedWheelTimer的构造方法入手,先说明一下
构造方法
1 | //附上文档说明,自行阅读 |
createWheel
1 | private static HashedWheelBucket[] createWheel(int ticksPerWheel) { |
wheel其实是一个bucket数组
HashedWheelBucket
1 | /** |
bucket的结构是一个带有头节点指针和尾节点指针的linked-list
newTimeout
HashedWheelTimer初始化后,看看怎样增加一个任务(在HashedWheelTimer内部统一叫HashedWheelTimeout,缩写timeout,从名字已经可以看出HashedWheelTimer的作用)
1 | public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) { |
HashedWheelTimeout
timeout的结构
1 | private static final class HashedWheelTimeout implements Timeout { |
Worker run
一个HashedWheelTimer只有一个Worker线程。看看Worker的初始化
1 | private final Worker worker = new Worker(); |
Worker继承Runnable,我们看run方法
1 | public void run() { |
图解
画了个图给大家体会一下
MpscQueue队列
HashedWheelTimer用到的timeouts
和cancelledTimeouts
都是一种MpscQueue
队列的数据结构。
MpscQueue全称Multi-Producer Single-Consumer Queue,从名字看出,是一种适合于多个生产者,单个消费者的高并发场景的高性能的,无锁的队列,原来Netty是自己实现了一个,但在最新的版本用了JCTools的,大家有兴趣可以了解一下。
多层时间轮
当时间跨度很大时,提升单层时间轮的 tickDuration 可以减少空转次数,但会导致时间精度变低,层级时间轮既可以避免精度降低,又避免了指针空转的次数。如果有时间跨度较长的定时任务,则可以交给层级时间轮去调度。
设想一下一个定时了 3 天,10 小时,50 分,30 秒的定时任务,在 tickDuration = 1s 的单层时间轮中,需要经过:3246060+106060+5060+30 次指针的拨动才能被执行。但在 wheel1 tickDuration = 1 天,wheel2 tickDuration = 1 小时,wheel3 tickDuration = 1 分,wheel4 tickDuration = 1 秒 的四层时间轮中,只需要经过 3+10+50+30 次指针的拨动。
如图所示:
缺点
HashedWheelTimer也有一些缺点,在使用场景上要注意一下
- Netty的HashedWheelTimer只支持单层的时间轮
- 当前一个任务执行时间过长的时候,会影响后续任务的到期执行时间的,也就是说其中的任务是串行执行的,所以,要求里面的任务都要短平快
延迟任务方案对比
HashedWheelTimer本质上也是一个延迟队列,我们跟其他延迟类解决方案对比一下
- 数据库轮询
比较常用的一种方法,数据先保存在数据库中,然后启动一个定时Job,根据时间或状态把数据捞出来,处理后再更新回数据库。这种方式很简单,不会引入其他的技术,开发周期短。如果数据量比较大,千万级甚至更多,插入频率很高的话,上面的方式在性能上会出现一些问题,查找和更新对会占用很多时间,轮询频率高的话甚至会影响数据入库。如果数据量进一步增大,那扫数据库肯定就不行了。另一方面,对于订单这类数据,我们也许会遇到分库分表,那上述方案就会变得过于复杂,得不偿失。
不过,优点是数据得到持久化,有问题可以查看。 DelayQueue
DelayQueue本质是PriorityQueue,每次插入或删除任务都要调整堆,复杂度是O(logN),相对HashedWheelTimer的O(1)来说有性能消耗。ScheduledExecutorService
其本质也是类似DelayQueue,不过ScheduledExecutorService是多线程的方式执行,可以基本保证其他任务的准时进行。ScheduledExecutorService封装较好,方便使用,还支持周期性任务。
总结
HashedWheelTimer时间轮是一个高性能,低消耗的数据结构,它适合用非准实时,延迟的短平快任务,例如心跳检测。
参考资料
https://zacard.net/2016/12/02/netty-hashedwheeltimer/
https://www.ctolib.com/topics-113116.html
https://www.cnkirito.moe/timer/