时间轮源码
前面把原理理解到位了,接下来就可以看一下我们的源码了。
先说明一下,为了方便我截图,下面的部分截图我是移动了源码的位置,所以可能和你看源码的时候有点不一样。
我们再次审视 Dubbo 的 FailbackClusterInvoker 类中关于时间轮的用法。
首先 failTimer 这个对象,是一个很眼熟的双重检查的单例模式:
这里初始化的 failTimer 就是 HashedWheelTimer 对象关键的逻辑是调用了它的构造方法。
所以,我们先从它的构造方法入手,开始撕它。
先说一下它的几个入参分别是干啥的:
- threadFactory:线程工厂,可以指定线程的名称和是否是守护进程。
- tickDuration:两个 tick 之间的时间间隔。
- unit:tickDuration 的时间单位。
- ticksPerWheel:时间轮里面的 tick 的个数。
- maxPendingTimeouts:时间轮中最大等待任务的个数。
所以,Dubbo 这个时间轮的含义就是这样的:
创建一个线程名称为 failback-cluster-timer 的守护线程,每隔一秒执行一次任务。这个时间轮的大小为 32,最大的等待处理任务个数是 failbackTasks,这个值是可以配置的,默认值是 100。
但是很多其他的使用场景下,比如 Dubbo 检查调用是否超时,就没有送 maxPendingTimeouts 这个值:
org.apache.dubbo.remoting.exchange.support.DefaultFuture#TIME_OUT_TIMER
它甚至连 ticksPerWheel 都没有上送。
其实这两个参数都是有默认值的。ticksPerWheel 默认为 512。maxPendingTimeouts 默认为 -1,含义为对等待处理的任务个数不限制:
好了,现在我们整体看一下这个时间轮的构造方法,每一行的作用我都写上了注释:
有几个地方,我也单独拿出来给你说一下。
比如 createWheel 这个方法,如果你八股文背的熟悉的话,你就知道这里和 HashMap 里面确认容量的核心代码是一样一样的。
这也是我在源码注释里面提到的,时间轮里面数组的大小必须是 2 的 n 次方。
为什么,你问我为什么?
别问,问就是为了后面做位运算,操作骚,速度快,逼格高。
我相信下面的这一个代码片段不需要我来解释了,你要是不理解,就再去翻一番 HashMap 的八股文:
但是这一行代码我还是可以多说一句的 mask = wheel.length - 1
。
因为我们已经知道 wheel.length 是 2 的 n 次方。
那么假设我们的定时任务的延迟执行时间是 x,那么它应该在时间轮的哪个格子里面呢?
是不是应该用 x 对长度取余,也就是这样计算: x % wheel.length。
但是,取余操作的效率其实不算高。
那么怎么能让这个操作快起来呢?
就是 wheel.length - 1。
wheel.length 是 2 的 n 次方,减一之后它的二级制的低位全部都是 1,举个例子就是这样式儿的:
所以 x % wheel.length = x & (wheel.length - 1)。
在源码里面 mask =wheel.length - 1。
那么 mask 在哪用的呢?
其中的一个地方就是在 Worker 类的 run 方法里面:
org.apache.dubbo.common.timer.HashedWheelTimer.Worker
这里计算出来的 idx 就是当前需要处理的数组的下标。
我这里只是告诉你 mask 确实是参与了 & 位运算,所以你看不懂这块的代码也没有关系,因为我还没讲到这里来。
所以没跟上的同学不要慌,我们接着往下看。
前面我们已经有一个时间轮了,那么怎么调用这个时间呢?
其实就是调用它的 newTimeout 方法:
这个方法有三个入参:
含义很明确,即指定任务(task)在指定时间(delay,unit)之后开始触发。
接下来解读一下 newTimeout 方法:
里面最关键的代码是 start 方法,我带大家看一下到底是在干啥:
分成上下两部分讲。
上面其实就是维护或者判断当前 HashedWheelTimer 的状态,从源码中我们知道状态有三个取值:
- 0:初始化
- 1:已启动
- 2:已关闭
如果是初始化,那么通过一个 cas 操作,把状态更新为已启动,并执行 workerThread.start() 操作,启动 worker 线程。
下面这个部分就稍微有一点点费解了。
如果 startTime 等于 0,即没有被初始化的话,就调用 CountDownLatch 的 await 等待一下下。
而且这个 await 还是在主线程上的 await,主线程在这里等着 startTime 被初始化,这是个什么逻辑呢?
首先,我们要找一下 startTime 是在哪儿被初始化的。
就是在 Worker 的 run 方法里面,而这个方法就是在前面 workerThread.start() 的时候触发的:
org.apache.dubbo.common.timer.HashedWheelTimer.Worker
可以看到,对 startTime 初始化完成后,还判断了是否等于 0。也就是说 System.nanoTime() 方法是有可能返回为 0,一个小细节,如果你去要深究一下的话,也是很有趣的,我这里就不展开了。
startTime 初始化完成之后,立马执行了 startTimeInitialized.countDown() 操作。
这不就和这里呼应起来了吗?
主线程不马上就可以跑起来了吗?
那么问题就来了,这里大费周章的搞一个 startTime 初始化,搞不到主线程还不能继续往下执行是干啥呢?
当然是有用啦,回到 newTimeout 方法接着往下看:
我们分析一下上面这个等式哈。
首先 System.nanoTime() 是代码执行到这个地方的实时时间。
因为 delay 是一个固定值,所以 unit.toNanos(delay) 也是一个固定值。
那么 System.nanoTime()+unit.toNanos(delay) 就是这个任务需要被触发的纳秒数。
举个例子。
假设 System.nanoTime() = 1000,unit.toNanos(delay)=100。
那么这个任务被触发的时间点就是 1000+100=1100。
这个能跟上吧?
那么为什么要减去 startTime 呢?
startTime 我们前面分析了,其实初始化的时候也是 System.nanoTime(),初始化完成后就是一个固定值了。
那岂不是 System.nanoTime()-startTime 几乎趋近于 0?
这个等式 System.nanoTime()+unit.toNanos(delay)-startTime 的意义是什么呢?
是的,这就是我当时看源码的一个疑问。
但是后面我分析出来,其实整个等式里面只有 System.nanoTime() 是一个变量。
第一次计算的时候 System.nanoTime()-startTime 确实趋近于 0,但是当第二次触发的时候,即第二个任务来的时候,计算它的 deadline 的时候,System.nanoTime() 可是远大于 startTime 这个固定值的。
所以,第二次任务的执行时间点应该是当前时间加上指定的延迟时间减去 worker 线程的启动时间,后面的时间以此类推。
前面 newTimeout 方法就分析完了,也就是主线程在这个地方就执行完时间轮相关的逻辑了。
接下来该分析什么呢?
肯定是该轮到时间轮的 worker 线程上场发挥了啊。
worker 线程的逻辑都在 run 方法里面。
而核心逻辑就在一个 do-while 里面:
循环结束的条件是当前时间轮的状态不是启动状态。
也就是说,只要时间轮没有被调用 stop 逻辑,这个线程会一直在运行。
接下来我们逐行看一下循环里面的逻辑,这部分逻辑就是时间轮的核心逻辑。
首先是 final long deadline = waitForNextTick()
这一行,里面就很有故事:
首先你看这个方法名你就知道它是干啥的了。
是在这里面等待,直到下一个时刻的到来。
所以方法进来第一行就是计算下一个时刻的纳秒值是啥。
接着看 for 循环里面,前面部分都看的比较懵逼,只有标号为 ③ 的地方好理解的多,就是让当前线程睡眠指定时间。
所以前面的部分就是在算这个指定时间是什么。
怎么算的呢?
标号为 ① 的地方,前面部分还能看懂,
deadline - currentTime 算出来的就是还需要多长时间才会到下一个时间刻度。
后面直接就看不懂了。
里面的 1000000 好理解,单位是纳秒,换算一下就是 1 毫秒。
这个 999999 是啥玩意?
其实这里的 999999 是为了让算出来的值多 1 毫秒。
比如,deadline - currentTime 算出来是 1000123 纳秒,那么 1000123/1000000=1ms。
但是(1000123+999999)/1000000=2ms。
也就是说要让下面标号为 ③ 的地方,多睡 1ms。
这是为什么呢?
我也不知道,所以我先暂时不管了,留个坑嘛,问题不大,接着往下写。
下面就到了标号为 ② 的地方,看起来是对 windows 操作系统进行了特殊的处理,要把 sleepTimeMs 换算为 10 的倍数。
为啥?
这里我就得批评一下 Dubbo 了,把 Netty 的实现拿过来了,还把关键信息给隐藏了,这不合适吧。
这地方在 Netty 的源码中是这样的:
这里很清晰的指了个路:
而顺着这条路,一路往下跟,会找到这样一个地方:
https://www.javamex.com/tutorials/threads/sleep_issues.shtml
没想到还有意外收获。
第一个划线的地方大概意思是说当线程调用 Thread.sleep 方法的时候,JVM 会进行一个特殊的调用,将中断周期设置为 1ms。
因为 Thread.sleep 方法的实现是依托于操作系统提供的中断检查,也就是操作系统会在每一个中断的时候去检查是否有线程需要唤醒并且提供 CPU 资源。所以我觉得前面多睡 1ms 的原因就可以用这个原因来解释了。
前面留的坑,这么快就填上了,舒服。
而第二个划线的地方说的是,如果是 windows 的话,中断周期可能是 10ms 或者 15ms,具体和硬件相关。
所以,如果是 windows 的话,需要把睡眠时间调整为 10 的倍数。
一个没啥卵用的知识,送给你。
前面几个问题了解清楚了,waitForNextTick 方法也就理解到位了,它干的事儿就是等,等一个时间刻度的时间,等一个 tick 长度的时间。
等到了之后呢?
就来到了这一行代码 int idx = (int) (tick & mask)
我们前面分析过,计算当前时间对应的下标,位运算,操作骚,速度快,逼格高,不多说。
然后代码执行到这个方法 processCancelledTasks()
看方法名称就知道了,是处理被取消的任务的队列:
逻辑很简单,一目了然,就是把 cancelledTimeouts 队列给清空。
这里是在 remove,在清理。
那么哪里在 add,在添加呢?
就是在下面这个方法中:
org.apache.dubbo.common.timer.HashedWheelTimer.HashedWheelTimeout#cancel
如果调用了 HashedWheelTimeout 的 cancel 方法,那么这个任务就算是被取消了。
前面画图的时候就提到了这个方法,逻辑也很清晰,所以不多解释了。
但是你注意我画了下划线的地方:MpscLinkedQueue。
这是个啥?
这是一个非常牛逼的无锁队列。
但是 Dubbo 这里的 cancelledTimeouts 队列的数据结构明明用的是 LinkedBlockingQueue 呀?
怎么回事呢?
因为这里的注释是 Netty 里面的,Netty 里面用的是 MpscLinkedQueue。
你看我给你对比一下 Netty 和 Dubbo 这里的区别:
好了,我们接着往下卷,来到了这行代码 HashedWheelBucket bucket=wheel[idx]
一目了然,没啥说的。
从时间轮里面获取指定下标的 bucket。
主要看看它下面的这一行代码 transferTimeoutsToBuckets()
我还是每一行都加上注释:
所以这个方法的核心逻辑就是把等待分配的任务都发配到指定的 bucket 上去。
这里也就回答了我画图的时候留下的一个问题:什么时候把等待分配队列里面的任务挂到时间轮上去呢?
就是这个时候。
接下来分析 bucket.expireTimeouts(deadline)
这一行代码。
你看这个方法的调用方就是 bucket,它代表的含义就是准备开始处理这个 bucket 里面的这个链表中的任务了:
最后说一句
好了,看到了这里了,转发、在看、点赞随便安排一个吧,要是你都安排上我也不介意。写文章很累的,需要一点正反馈。
给各位读者朋友们磕一个了: