前言

我在2. SOFAJRaft源码分析—JRaft的定时任务调度器是怎么做的?这篇文章里已经讲解过时间轮算法在JRaft中是怎么应用的,但是我感觉我并没有讲解清楚这个东西,导致看了这篇文章依然和没看是一样的,所以我打算重新说透时间轮算法。

时间轮的应用并非 JRaft 独有,其应用场景还有很多,在 Netty、Akka、Quartz、ZooKeeper 、Kafka等组件中都存在时间轮的踪影。

我们下面讲解的时间轮的实现以JRaft中的为例子进行讲解,因为JRaft这部分的代码是参考Netty的,所以大家也可以去Netty中去寻找源码实现。

时间轮用来解决什么问题?

如果一个系统中存在着大量的调度任务,而大量的调度任务如果每一个都使用自己的调度器来管理任务的生命周期的话,浪费cpu的资源并且很低效。

时间轮是一种高效来利用线程资源来进行批量化调度的一种调度模型。把大批量的调度任务全部都绑定到同一个的调度器上面,使用这一个调度器来进行所有任务的管理(manager),触发(trigger)以及运行(runnable)。能够高效的管理各种延时任务,周期任务,通知任务等等。

不过,时间轮调度器的时间精度可能不是很高,对于精度要求特别高的调度任务可能不太适合。因为时间轮算法的精度取决于,时间段“指针”单元的最小粒度大小,比如时间轮的格子是一秒跳一次,那么调度精度小于一秒的任务就无法被时间轮所调度。

时间轮结构

如图,JRaft中时间轮(HashedWheelTimer)是一个存储定时任务的环形队列,底层采用数组实现,数组中的每个元素可以存放一个定时任务列表(HashedWheelBucket),HashedWheelBucket是一个环形的双向链表,链表中的每一项表示的都是定时任务项(HashedWheelTimeout),其中封装了真正的定时任务(TimerTask)。

时间轮由多个时间格组成,每个时间格代表当前时间轮的基本时间跨度(tickDuration)。时间轮的时间格个数是固定的,可用 wheel.length 来表示。

时间轮还有一个表盘指针(tick),用来表示时间轮当前指针跳动的次数,可以用tickDuration * (tick + 1)来表示下一次到期的任务,需要处理此时间格所对应的 HashedWheelBucket 中的所有任务。

时间轮运行逻辑

时间轮在启动的时候会记录一下当前启动的时间赋值给startTime。时间轮在添加任务的时候首先会计算延迟时间(deadline),比如一个任务的延迟时间为24ms,那么会将当前的时间(currentTime)+24ms-时间轮启动时的时间(startTime)。然后将任务封装成HashedWheelTimeout加入到timeouts队列中,作为缓存。

时间轮在运行的时候会将timeouts中缓存的HashedWheelTimeout任务取10万个出来进行遍历。

然后需要计算出几个参数值:

  1. HashedWheelTimeout的总共延迟的次数:将每个任务的延迟时间(deadline)/tickDuration 计算出tick需要总共跳动的次数;
  2. 计算时间轮round次数:根据计算的需要走的(总次数- 当前tick数量)/ 时间格个数(wheel.length)。比如tickDuration为1ms,时间格个数为20个,那么时间轮走一圈需要20ms,那么添加进一个延时为24ms的数据,如果当前的tick为0,那么计算出的轮数为1,指针没运行一圈就会将round取出来减一,所以需要转动到第二轮之后才可以将轮数round减为0之后才会运行
  3. 计算出该任务需要放置到时间轮(wheel)的槽位,然后加入到槽位链表最后

将timeouts中的数据放置到时间轮wheel中之后,计算出当前时针走到的槽位的位置,并取出槽位中的链表数据,将deadline和当前的时间做对比,运行过期的数据。

源码分析

构造器

public HashedWheelTimer(ThreadFactory threadFactory, long tickDuration, TimeUnit unit, int ticksPerWheel,
long maxPendingTimeouts) { if (threadFactory == null) {
throw new NullPointerException("threadFactory");
}
//unit = MILLISECONDS
if (unit == null) {
throw new NullPointerException("unit");
}
if (tickDuration <= 0) {
throw new IllegalArgumentException("tickDuration must be greater than 0: " + tickDuration);
}
if (ticksPerWheel <= 0) {
throw new IllegalArgumentException("ticksPerWheel must be greater than 0: " + ticksPerWheel);
} // Normalize ticksPerWheel to power of two and initialize the wheel.
// 创建一个HashedWheelBucket数组
// 创建时间轮基本的数据结构,一个数组。长度为不小于ticksPerWheel的最小2的n次方
wheel = createWheel(ticksPerWheel);
// 这是一个标示符,用来快速计算任务应该呆的格子。
// 我们知道,给定一个deadline的定时任务,其应该呆的格子=deadline%wheel.length.但是%操作是个相对耗时的操作,所以使用一种变通的位运算代替:
// 因为一圈的长度为2的n次方,mask = 2^n-1后低位将全部是1,然后deadline&mast == deadline%wheel.length
// java中的HashMap在进行hash之后,进行index的hash寻址寻址的算法也是和这个一样的
mask = wheel.length - 1; // Convert tickDuration to nanos.
//tickDuration传入是1的话,这里会转换成1000000
this.tickDuration = unit.toNanos(tickDuration); // Prevent overflow.
// 校验是否存在溢出。即指针转动的时间间隔不能太长而导致tickDuration*wheel.length>Long.MAX_VALUE
if (this.tickDuration >= Long.MAX_VALUE / wheel.length) {
throw new IllegalArgumentException(String.format(
"tickDuration: %d (expected: 0 < tickDuration in nanos < %d", tickDuration, Long.MAX_VALUE
/ wheel.length));
}
//将worker包装成thread
workerThread = threadFactory.newThread(worker);
//maxPendingTimeouts = -1
this.maxPendingTimeouts = maxPendingTimeouts; //如果HashedWheelTimer实例太多,那么就会打印一个error日志
if (instanceCounter.incrementAndGet() > INSTANCE_COUNT_LIMIT
&& warnedTooManyInstances.compareAndSet(false, true)) {
reportTooManyInstances();
}
}

在这个构造器中有几个细节需要注意:

  1. 调用createWheel方法创建的wheel数组一定是2次方数,比如传入的ticksPerWheel是6,那么初始化的wheel长度一定是8。这样做是为了让mask & tick 来计算出槽位
  2. tickDuration用的是纳秒
  3. 在构造里面并不会里面启动时间轮,而是要等到有第一个任务加入到时间轮的时候才启动。在构造器里面会将工作线程worker封装成workerThread

放入任务到时间轮中

public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
if (task == null) {
throw new NullPointerException("task");
}
if (unit == null) {
throw new NullPointerException("unit");
} long pendingTimeoutsCount = pendingTimeouts.incrementAndGet(); if (maxPendingTimeouts > 0 && pendingTimeoutsCount > maxPendingTimeouts) {
pendingTimeouts.decrementAndGet();
throw new RejectedExecutionException("Number of pending timeouts (" + pendingTimeoutsCount
+ ") is greater than or equal to maximum allowed pending "
+ "timeouts (" + maxPendingTimeouts + ")");
}
// 如果时间轮没有启动,则启动
start(); // Add the timeout to the timeout queue which will be processed on the next tick.
// During processing all the queued HashedWheelTimeouts will be added to the correct HashedWheelBucket.
long deadline = System.nanoTime() + unit.toNanos(delay) - startTime; // Guard against overflow.
//在delay为正数的情况下,deadline是不可能为负数
//如果为负数,那么说明超过了long的最大值
if (delay > 0 && deadline < 0) {
deadline = Long.MAX_VALUE;
}
// 这里定时任务不是直接加到对应的格子中,而是先加入到一个队列里,然后等到下一个tick的时候,
// 会从队列里取出最多100000个任务加入到指定的格子中
HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
//Worker会去处理timeouts队列里面的数据
timeouts.add(timeout);
return timeout;
}
  1. 如果时间轮没有启动,那么就调用start方法启动时间轮,启动时间轮之后会为startTime设置为当前时间
  2. 计算延迟时间deadline
  3. 将task任务封装到HashedWheelTimeout中,然后添加到timeouts队列中进行缓存

start

private final CountDownLatch                                     startTimeInitialized   = new CountDownLatch(1);

public void start() {
//workerState一开始的时候是0(WORKER_STATE_INIT),然后才会设置为1(WORKER_STATE_STARTED)
switch (workerStateUpdater.get(this)) {
case WORKER_STATE_INIT:
//使用cas来获取启动调度的权力,只有竞争到的线程允许来进行实例启动
if (workerStateUpdater.compareAndSet(this, WORKER_STATE_INIT, WORKER_STATE_STARTED)) {
//如果成功设置了workerState,那么就调用workerThread线程
workerThread.start();
}
break;
case WORKER_STATE_STARTED:
break;
case WORKER_STATE_SHUTDOWN:
throw new IllegalStateException("cannot be started once stopped");
default:
throw new Error("Invalid WorkerState");
} // 等待worker线程初始化时间轮的启动时间
// Wait until the startTime is initialized by the worker.
while (startTime == 0) {
try {
//这里使用countDownLauch来确保调度的线程已经被启动
startTimeInitialized.await();
} catch (InterruptedException ignore) {
// Ignore - it will be ready very soon.
}
}
}

start方法会根据当前的workerState状态来启动时间轮。并且用了startTimeInitialized来控制线程的运行,如果workerThread没有启动起来,那么newTimeout方法会一直阻塞在运行start方法中。如果不阻塞,newTimeout方法会获取不到startTime。

启动时间轮

时间轮的启动在HashedWheelTimer的内部类Worker中。调用workerThread#start方法会调用Worker的run方法启动时间轮。

下面我们看时间轮启动做了什么,下面的分析不考虑任务被取消的情况。

Worker#run

public void run() {
// Initialize the startTime.
startTime = System.nanoTime();
if (startTime == 0) {
// We use 0 as an indicator for the uninitialized value here, so make sure it's not 0 when initialized.
startTime = 1;
} //HashedWheelTimer的start方法会继续往下运行
// Notify the other threads waiting for the initialization at start().
startTimeInitialized.countDown(); do {
//返回的是当前的nanoTime- startTime
//也就是返回的是 每 tick 一次的时间间隔
final long deadline = waitForNextTick();
if (deadline > 0) {
//算出时间轮的槽位
int idx = (int) (tick & mask);
//移除cancelledTimeouts中的bucket
// 从bucket中移除timeout
processCancelledTasks();
HashedWheelBucket bucket = wheel[idx];
// 将newTimeout()方法中加入到待处理定时任务队列中的任务加入到指定的格子中
transferTimeoutsToBuckets();
bucket.expireTimeouts(deadline);
tick++;
}
// 校验如果workerState是started状态,那么就一直循环
} while (workerStateUpdater.get(HashedWheelTimer.this) == WORKER_STATE_STARTED); // Fill the unprocessedTimeouts so we can return them from stop() method.
for (HashedWheelBucket bucket : wheel) {
bucket.clearTimeouts(unprocessedTimeouts);
}
for (;;) {
HashedWheelTimeout timeout = timeouts.poll();
if (timeout == null) {
break;
}
//如果有没有被处理的timeout,那么加入到unprocessedTimeouts对列中
if (!timeout.isCancelled()) {
unprocessedTimeouts.add(timeout);
}
}
//处理被取消的任务
processCancelledTasks();
}
  1. 时间轮运行的时候首先会记录一下启动时间(startTime),然后调用startTimeInitialized释放外层的等待线程;
  2. 进入dowhile循环,调用waitForNextTick睡眠等待到下一次的tick指针的跳动,并返回当前时间减去startTime作为deadline
  3. 由于mask= wheel.length -1 ,wheel是2的次方数,所以可以直接用tick & mask 计算出此次在wheel中的槽位
  4. 调用processCancelledTasks将cancelledTimeouts队列中的任务取出来,并将当前的任务从时间轮中移除
  5. 调用transferTimeoutsToBuckets方法将timeouts队列中缓存的数据取出加入到时间轮中
  6. 运行目前指针指向的槽位中的bucket链表数据

时间轮指针跳动

waitForNextTick

//sleep, 直到下次tick到来, 然后返回该次tick和启动时间之间的时长
private long waitForNextTick() {
//tickDuration这里是100000
//tick表示总tick数
long deadline = tickDuration * (tick + 1); for (;;) {
final long currentTime = System.nanoTime() - startTime;
// 计算需要sleep的时间, 之所以加999999后再除10000000,前面是1所以这里需要减去1,
// 才能计算准确,还有通过这里可以看到 其实线程是以睡眠一定的时候再来执行下一个ticket的任务的,
//这样如果ticket的间隔设置的太小的话,系统会频繁的睡眠然后启动,
//其实感觉影响部分的性能,所以为了更好的利用系统资源步长可以稍微设置大点
long sleepTimeMs = (deadline - currentTime + 999999) / 1000000;
//sleepTimeMs小于零表示走到了下一个时间轮位置
if (sleepTimeMs <= 0) {
if (currentTime == Long.MIN_VALUE) {
return -Long.MAX_VALUE;
} else {
return currentTime;
}
} // Check if we run on windows, as if thats the case we will need
// to round the sleepTime as workaround for a bug that only affect
// the JVM if it runs on windows.
//
// See https://github.com/netty/netty/issues/356
if (Platform.isWindows()) {
sleepTimeMs = sleepTimeMs / 10 * 10;
} try {
Thread.sleep(sleepTimeMs);
} catch (InterruptedException ignored) {
if (workerStateUpdater.get(HashedWheelTimer.this) == WORKER_STATE_SHUTDOWN) {
return Long.MIN_VALUE;
}
}
}
}

可以想象一下在时钟的秒钟上面秒与秒之间的时间是需要等待的,那么waitForNextTick这个方法就是根据当前的时间计算出跳动到下个时间的间隔时间,并进行sleep操作,然后返回当前时间距离时间轮启动时间的时间段。

转移任务到时间轮中

在调用时间轮的方法加入任务的时候并没有直接加入到时间轮中,而是缓存到了timeouts队列中,所以在运行的时候需要将timeouts队列中的任务转移到时间轮数据的链表中

transferTimeoutsToBuckets

private void transferTimeoutsToBuckets() {
// transfer only max. 100000 timeouts per tick to prevent a thread to stale the workerThread when it just
// adds new timeouts in a loop.
// 每次tick只处理10w个任务,以免阻塞worker线程
for (int i = 0; i < 100000; i++) {
HashedWheelTimeout timeout = timeouts.poll();
if (timeout == null) {
// all processed
break;
}
//已经被取消了;
if (timeout.state() == HashedWheelTimeout.ST_CANCELLED) {
// Was cancelled in the meantime.
continue;
}
//calculated = tick 次数
long calculated = timeout.deadline / tickDuration;
// 计算剩余的轮数, 只有 timer 走够轮数, 并且到达了 task 所在的 slot, task 才会过期
timeout.remainingRounds = (calculated - tick) / wheel.length;
//如果任务在timeouts队列里面放久了, 以至于已经过了执行时间, 这个时候就使用当前tick, 也就是放到当前bucket, 此方法调用完后就会被执行
final long ticks = Math.max(calculated, tick); // Ensure we don't schedule for past.
//// 算出任务应该插入的 wheel 的 slot, slotIndex = tick 次数 & mask, mask = wheel.length - 1
int stopIndex = (int) (ticks & mask); HashedWheelBucket bucket = wheel[stopIndex];
//将timeout加入到bucket链表中
bucket.addTimeout(timeout);
}
}

在这个转移方法中,写死了一个循环,每次都只转移10万个任务。

然后根据HashedWheelTimeout的deadline延迟时间计算出时间轮需要运行多少次才能运行当前的任务,如果当前的任务延迟时间大于时间轮跑一圈所需要的时间,那么就计算需要跑几圈才能到这个任务运行。

最后计算出该任务在时间轮中的槽位,添加到时间轮的链表中。

运行时间轮中的任务

当指针跳到时间轮的槽位的时间,会将槽位的HashedWheelBucket取出来,然后遍历链表,运行其中到期的任务。

expireTimeouts

// 过期并执行格子中的到期任务,tick到该格子的时候,worker线程会调用这个方法
//根据deadline和remainingRounds判断任务是否过期
public void expireTimeouts(long deadline) {
HashedWheelTimeout timeout = head; // process all timeouts
//遍历格子中的所有定时任务
while (timeout != null) {
// 先保存next,因为移除后next将被设置为null
HashedWheelTimeout next = timeout.next;
if (timeout.remainingRounds <= 0) {
//从bucket链表中移除当前timeout,并返回链表中下一个timeout
next = remove(timeout);
//如果timeout的时间小于当前的时间,那么就调用expire执行task
if (timeout.deadline <= deadline) {
timeout.expire();
} else {
//不可能发生的情况,就是说round已经为0了,deadline却>当前槽的deadline
// The timeout was placed into a wrong slot. This should never happen.
throw new IllegalStateException(String.format("timeout.deadline (%d) > deadline (%d)",
timeout.deadline, deadline));
}
} else if (timeout.isCancelled()) {
next = remove(timeout);
} else {
//因为当前的槽位已经过了,说明已经走了一圈了,把轮数减一
timeout.remainingRounds--;
}
//把指针放置到下一个timeout
timeout = next;
}
}

HashedWheelBucket是一个链表,所以我们需要从head节点往下进行遍历。如果链表没有遍历到链表尾部那么就继续往下遍历。

获取的timeout节点节点,如果剩余轮数remainingRounds大于0,那么就说明要到下一圈才能运行,所以将剩余轮数减一;

如果当前剩余轮数小于等于零了,那么就将当前节点从bucket链表中移除,并判断一下当前的时间是否大于timeout的延迟时间,如果是则调用timeout的expire执行任务。

时间轮算法(TimingWheel)是如何实现的?的更多相关文章

  1. 时间轮算法的定时器(Delphi)

    源码下载 http://files.cnblogs.com/lwm8246/uTimeWheel.rar D7,XE2 编译测试OK //时间轮算法的定时器 //-- : QQ unit uTimeW ...

  2. 时间轮算法在Netty和Kafka中的应用,为什么不用Timer、延时线程池?

    大家好,我是yes. 最近看 Kafka 看到了时间轮算法,记得以前看 Netty 也看到过这玩意,没太过关注.今天就来看看时间轮到底是什么东西. 为什么要用时间轮算法来实现延迟操作? 延时操作 Ja ...

  3. .Net之时间轮算法(终极版)定时任务

    TimeWheelDemo 一个基于时间轮原理的定时器 对时间轮的理解 其实我是有一篇文章(.Net 之时间轮算法(终极版))针对时间轮的理论理解的,但是,我想,为啥我看完时间轮原理后,会采用这样的方 ...

  4. .Net 之时间轮算法(终极版)

    关于时间轮算法的起始 我也认真的看了时间轮算法相关,大致都是如下的一个图 个人认为的问题 大部分文章在解释这个为何用时间轮的时候都再说 假设我们现在有一个很大的数组,专门用于存放延时任务.它的精度达到 ...

  5. 延时任务-基于netty时间轮算法实现

    一.时间轮算法简介 为了大家能够理解下文中的代码,我们先来简单了解一下netty时间轮算法的核心原理 时间轮算法名副其实,时间轮就是一个环形的数据结构,类似于表盘,将时间轮分成多个bucket(比如: ...

  6. [从源码学设计]蚂蚁金服SOFARegistry之时间轮的使用

    [从源码学设计]蚂蚁金服SOFARegistry之时间轮的使用 目录 [从源码学设计]蚂蚁金服SOFARegistry之时间轮的使用 0x00 摘要 0x01 业务领域 1.1 应用场景 0x02 定 ...

  7. kafka时间轮的原理(一)

    概述 早就想写关于kafka时间轮的随笔了,奈何时间不够,技术感觉理解不到位,现在把我之前学习到的进行整理一下,以便于以后并不会忘却.kafka时间轮是一个时间延时调度的工具,学习它可以掌握更加灵活先 ...

  8. Kafka解惑之时间轮 (TimingWheel)

    Kafka中存在大量的延迟操作,比如延迟生产.延迟拉取以及延迟删除等.Kafka并没有使用JDK自带的Timer或者DelayQueue来实现延迟的功能,而是基于时间轮自定义了一个用于实现延迟功能的定 ...

  9. 记录——时间轮定时器(lua 实现)

    很长一段时间里,我错误的认识了定时器.无意中,我发现了“时间轮”这个名词,让我对定时器有了新的看法. 我错误的认为,定时器只需要一个 tick 队列,按指定的时间周期遍历队列,检查 tick 倒计时满 ...

随机推荐

  1. 使用boost data_time模块来获取毫秒级时间并转换为string字符串

    本文首发于个人博客https://kezunlin.me/post/107cbcbb/,欢迎阅读! using boost.date_time to get time in millisecond m ...

  2. 串烧 JavaCAS相关知识

    JMM与问题引入 为啥先说JMM,因为CAS的实现类中维护的变量都被volatile修饰, 这个volatile 是遵循JMM规范(不是百分百遵循,下文会说)实现的保证多线程并发访问某个变量实现线程安 ...

  3. ecryptfs

    ecryptfs是一种加密文件系统.该文件系统的内容在传输和储存时以密文形式存在.只有在mount时用密钥解密才能得到明文.利用这个特性,我们可以用他来对软件镜像中的部分敏感文件系统进行加密,然后打包 ...

  4. Oracle 分区表管理之组合分区(分区索引失效与性能比较)

    整体结构如下: Oracle  分区表管理之组合分区(分区索引失效与性能比较) 虽然老早就使用了分区表,终于有时间写有关分区表的内容:不是所有的场景数据量变大需要用到分区表,一般单表数据超过2g可以考 ...

  5. tensorflow多层CNN代码分析

    tf,reshape(tensor,shape,name=None) #其中shape为一个列表形式,特殊的一点是列表中可以存在-1.-1代表的含义是不用我们自己#指定这一维的大小,函数会自动计算,但 ...

  6. 记一次MySQL数据库导入错误

    昨天在转数据的时候,控制台报出了下面这个warning: Warning: Data truncated for column '控制距离' at row 1 字面上意思应该是:控制距离这一字段在第一 ...

  7. 【RN - 基础】之FlexBox弹性布局

    前言 弹性盒模型(The Flexible Box Module),又叫FlexBox,意为“弹性布局”,旨在通过弹性的方式来对齐和分布容器中内容的空间,使其能适应不同的屏幕,为盒装模型提供最大的灵活 ...

  8. 测试访问apiserver状态

    目录 前言 创建admin证书和私钥 分发kubeconfig文件 检查集群信息 授权kube-apiserver访问kubelet API的权限 前言 到这里,ETCD集群.kube-nginx + ...

  9. 升级Xcode7&iOS9后,出现NSURLSession/NSURLConnection HTTP load failed (kCFStreamErrorDomainSSL, -980X)

    在info.plist里面添加如下内容即可: <key>NSAppTransportSecurity</key> <dict> <key>NSAllow ...

  10. vscode从听说到使用,vetur,prettier,htmljscssPrettify踩坑指南。

    今天基于vue-cli(2.9.3)构建一个新的项目.我用的sublime,es6的代码格式要与公司格式兼容.采用了vue-cli自带的eslint后,有一些不统一的部分需要修改.先看看sublime ...