文章插图
可以看到任务并没有直接添加到时间轮中,而是先入了一个 mpsc 队列,我简单说下 mpsc 是 JCTools 中的并发队列,用在多个生产者可同时访问队列,但只有一个消费者会访问队列的情况 。篇幅有限,有兴趣的朋友自行了解实现 。
然后我们再来看看工作线程是如何运作的 。
文章插图
很直观没什么花头,我们先来看看 waitForNextTick,是如何得到下一次执行时间的 。
文章插图
简单的说就是通过 tickDuration 和此时已经滴答的次数算出下一次需要检查的时间,时候未到就sleep等着 。
再来看下任务如何入槽的 。
文章插图
注释的很清楚了,实现也和上述分析的一致 。
最后再来看下如何执行的 。
文章插图
就是通过轮数和时间双重判断,执行完了移除任务 。
小结一下总体上看 Netty 的实现就是上文说的时间轮通过轮数的实现,完全一致 。可以看出时间精度由 TickDuration 把控,并且工作线程的除了处理执行到时的任务还做了其他操作,因此任务不一定会被精准的执行 。
而且任务的执行如果不是新起一个线程,或者将任务扔到线程池执行,那么耗时的任务会阻塞下个任务的执行 。
并且会有很多无用的 tick 推进,例如 TickDuration 为1秒,此时就一个延迟350秒的任务,那就是有349次无用的操作 。
但是从另一面来看,如果任务都执行很快(当然你也可以异步执行),并且任务数很大,通过分批执行,并且增删任务的时间复杂度都是O(1)来说 。时间轮还是比通过优先队列实现的延时任务来的合适些 。
Kafka 中的时间轮上面我们说到 Kafka 中的时间轮是多层次时间轮实现,总的而言实现和上述说的思路一致 。不过细节有些不同,并且做了点优化 。
先看看添加任务的方法 。在添加的时候就设置任务执行的绝对时间 。
文章插图
那么时间轮是如何推动的呢?Netty 中是通过固定的时间间隔扫描,时候未到就等待来进行时间轮的推动 。上面我们分析到这样会有空推进的情况 。
而 Kafka 就利用了空间换时间的思想,通过 DelayQueue,来保存每个槽,通过每个槽的过期时间排序 。这样拥有最早需要执行任务的槽会有优先获取 。如果时候未到,那么 delayQueue.poll 就会阻塞着,这样就不会有空推进的情况发送 。
我们来看下推进的方法 。
文章插图
从上面的 add 方法我们知道每次对比都是根据expiration < currentTime + interval 来进行对比的,而advanceClock 就是用来推进更新 currentTime 的 。
小结一下Kafka 用了多层次时间轮来实现,并且是按需创建时间轮,采用任务的绝对时间来判断延期,并且对于每个槽(槽内存放的也是任务的双向链表)都会维护一个过期时间,利用 DelayQueue 来对每个槽的过期时间排序,来进行时间的推进,防止空推进的存在 。
每次推进都会更新 currentTime 为当前时间戳,当然做了点微调使得 currentTime 是 tickMs 的整数倍 。并且每次推进都会把能降级的任务重新插入降级 。
可以看到这里的 DelayQueue 的元素是每个槽,而不是任务,因此数量就少很多了,这应该是权衡了对于槽操作的延时队列的时间复杂度与空推进的影响 。
总结首先介绍了 Timer、DelayQueue 和 ScheduledThreadPool,它们都是基于优先队列实现的,O(logn) 的时间复杂度在任务数多的情况下频繁的入队出队对性能来说有损耗 。因此适合于任务数不多的情况 。
Timer 是单线程的会有阻塞的风险,并且对异常没有做处理,一个任务出错 Timer 就挂了 。而 ScheduledThreadPool 相比于 Timer 首先可以多线程来执行任务,并且线程池对异常做了处理,使得任务之间不会有影响 。
并且 Timer 和 ScheduledThreadPool 可以周期性执行任务 。而 DelayQueue 就是个具有优先级的阻塞队列 。
对比而言时间轮更适合任务数很大的延时场景,它的任务插入和删除时间复杂度都为O(1) 。对于延迟超过时间轮所能表示的范围有两种处理方式,一是通过增加一个字段-轮数,Netty 就是这样实现的 。二是多层次时间轮,Kakfa 是这样实现的 。
推荐阅读
- 关于TCP/IP,必须知道的十个知识点
- 你知道Python有内置数据库吗?Python内置库SQlite3使用指南
- 美国巨石阵之谜 英国的巨石阵谁建的
- 什么是几亿年前大量的低等生物经过长期复杂变化形成的 单细胞生物最早出现在地球上的时间
- 黄疸最晚多长时间退完
- 4个月婴儿睡眠时间
- 3个月婴儿睡眠时间
- 婴儿多大开始说话
- 可以长时间喝乌龙茶吗,杜仲茶可以隔夜喝吗
- 婴儿囟门受伤的表现