浅谈时间轮算法

1,467 阅读5分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

时间轮的由来

第一次接触时间轮这个概念是在Xxl-job这个定时任务框架中接触到得,当时得业务场景是定时关单。为此特意去熟悉了下Xxl-job这个框架。

Xxl-job的时间轮组成

Xxl-job的时间轮其实是一个由0-59s,每秒对应一个队列构成,由于这个队列存储的都是最近5秒(预读5s内)内将要被执行的定时任务,所以不存在圈次这个概念。

private volatile static Map<Integer, List<Integer>> ringData = new ConcurrentHashMap<>();

延迟任务入队列的逻辑:

  1. 计算该延迟任务执行的秒数ringSecond
  2. 根据ringSecond存入ringData中

// 1、make ring second 5%60=5 取模得 0-59 为环
int ringSecond = (int)((jobInfo.getTriggerNextTime()/1000)%60);

// 2push time ring 时间轮
pushTimeRing(ringSecond, jobInfo.getId());

实际存入时间轮的逻辑如下:

private void pushTimeRing(int ringSecond, int jobId){
    // push async ring
    List<Integer> ringItemData = ringData.get(ringSecond);
    if (ringItemData == null) {
        ringItemData = new ArrayList<Integer>();
        ringData.put(ringSecond, ringItemData);
    }
    // 加入环中
    ringItemData.add(jobId);

    logger.debug(">>>>>>>>>>> xxl-job, schedule push time-ring : " + ringSecond + " = " + Arrays.asList(ringItemData) );
}
Xxl-job 时间轮执行逻辑解密

由于ringData存储的都是以秒计时,而且秒之间不会存在竞争关系,我们只需要一个单独的线程循环获取当前的秒数(每60s一个轮回),去除当前秒对应的任务进行执行,然后进入休眠,等待时间到达下一秒即可;

Thread ringThread = new Thread(new Runnable() {
    @Override
    public void run() {

        // align second
        try {
            TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis()%1000 );
        } catch (InterruptedException e) {
            if (!ringThreadToStop) {
                logger.error(e.getMessage(), e);
            }
        }

        while (!ringThreadToStop) {

            try {
                // second data
                List<Integer> ringItemData = new ArrayList<>();
                // 获取当前秒数
                int nowSecond = Calendar.getInstance().get(Calendar.SECOND);   // 避免处理耗时太长,跨过刻度,向前校验一个刻度;
                // 当前秒数为49 取48 49 的数据
                for (int i = 0; i < 2; i++) {
                    // 此处删除 上面再添加有没有可能( 不可能 前面添加 只能向后添加)
                    List<Integer> tmpData = ringData.remove( (nowSecond+60-i)%60 );
                    if (tmpData != null) {
                        ringItemData.addAll(tmpData);
                    }
                }

                // ring trigger
                logger.debug(">>>>>>>>>>> xxl-job, time-ring beat : " + nowSecond + " = " + Arrays.asList(ringItemData) );
                if (ringItemData.size() > 0) {
                    // do trigger
                    for (int jobId: ringItemData) {
                        // do trigger
                        JobTriggerPoolHelper.trigger(jobId, TriggerTypeEnum.CRON, -1, null, null, null);
                    }
                    // clear
                    ringItemData.clear();
                }
            } catch (Exception e) {
                if (!ringThreadToStop) {
                    logger.error(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread error:{}", e);
                }
            }

            // next second, align second
            try {
                TimeUnit.MILLISECONDS.sleep(1000 - System.currentTimeMillis()%1000);
            } catch (InterruptedException e) {
                if (!ringThreadToStop) {
                    logger.error(e.getMessage(), e);
                }
            }
        }
        logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#ringThread stop");
    }
});
  1. 获取当前的秒数,取出任务交给线程池(fast、slow线程池)执行。
  2. ringThread线程首先休眠一定时间(小于1s)

根据Xxl-job的时间轮原理,我们发现猜想有没有一个更加细腻度的算法来首先更加复杂的延迟任务执行呢,比如:

  1. 延迟时间非常大,存在圈数的概念
  2. 可以对任务进行主动取消,提供相关查询功能。

HashedWheelTimer 精密的时间轮

HashedWheelTimer 使用介绍
1 引入jar
<dependency>
    <groupId>io.netty</groupId>
    <artifactId>netty-common</artifactId>
    <version>4.1.65.Final</version>
</dependency>
2 构建HashedWheelTimer 对象
 HashedWheelTimer hashedWheelTimer = new HashedWheelTimer(new DefaultThreadFactory("HashedWheelTimer-"), 100, TimeUnit.MILLISECONDS,
        1024);
  1. ThreadFactory threadFactory 线程工厂,主要作用于执行延迟任务的线程
  2. tickDuration和unit:时间刻度的长度,多久tick一次
  3. ticksPerWheel:时间轮的长度,一圈下来有多少格
  4. leakDetection:默认为true,开启泄漏检测
  5. maxPendingTimeouts:最大挂起Timeouts数量
3 提交延迟任务
for (int i=0;i<100;i++) {
    Timeout timeout = hashedWheelTimer.newTimeout(e -> {
        System.out.println(new Date());
    }, 3, TimeUnit.SECONDS);
}
任务进度获取api
public interface Timeout {

    /**
     * Returns the {@link Timer} that created this handle.
     */
    Timer timer();

    /**
     * Returns the {@link TimerTask} which is associated with this handle.
     */
    TimerTask task();

    /**
     * Returns {@code true} if and only if the {@link TimerTask} associated
     * with this handle has been expired.
     */
    boolean isExpired();

    /**
     * Returns {@code true} if and only if the {@link TimerTask} associated
     * with this handle has been cancelled.
     */
    boolean isCancelled();

    /**
     * Attempts to cancel the {@link TimerTask} associated with this handle.
     * If the task has been executed or cancelled already, it will return with
     * no side effect.
     *
     * @return True if the cancellation completed successfully, otherwise false
     */
    boolean cancel();
}
  1. isExpired 是否已过期
  2. isCancelled 是否取消
  3. cancel 取消当前任务
时间轮原理

image.png

再讲原理之前,存在几个问题需要解决;

  1. 任务执行是否需要多线程处理。
  2. 任务再执行过程中被其他线程锁修改或者执行。竞争关系如何维护。
  3. 如何计算执行时间延迟的任务放入到那个轮次中。

对于问题1,我们在分析了Xxl-job的原理后明显发现只需要一个工作线程即可。对于问题2,这个是一个老生常谈的问题,要么加锁,要么通过CAS+State的形式保持。对于问题3,我们得提炼一个公式进行计算坐标和轮次。

揭秘HashedWheelTimer
1 对象的构造流程

对于HashedWheelTimer的构建。

  1. 初始化wheel数组,数组长度必须是2的n次幂
  2. tickDuration时间轮刻度统一为纳秒
2.延迟任务提交

为了避免提交的延迟任务于当前正在执行的延迟任务相冲突,采用了生产者消费者模型,此处仅仅将延迟任务放到阻塞队列中,工作线程从阻塞队列取出任务放到指定的轮次上。

public Timeout newTimeout(TimerTask task, long delay, TimeUnit 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.
    if (delay > 0 && deadline < 0) {
        deadline = Long.MAX_VALUE;
    }
    HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
    timeouts.add(timeout);
    return timeout;
}

其中start方法的作用:

  1. 启动workThread 负责延迟任务执行
  2. 初始化startTime值。
  3. 计算延迟任务相对执行时间
long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;
3 延迟任务归位
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.
    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;
        }

        long calculated = timeout.deadline / tickDuration;
        timeout.remainingRounds = (calculated - tick) / wheel.length;

        final long ticks = Math.max(calculated, tick); // Ensure we don't schedule for past.
        int stopIndex = (int) (ticks & mask);

        HashedWheelBucket bucket = wheel[stopIndex];
        bucket.addTimeout(timeout);
    }
}

tickDuration为时间刻度,tick 为相对startTime而言,已过几个tickDuration,mask为数组长度-1;

  1. 从阻塞队列中取出任务,根据任务的相对执行时间计算该任务对应的轮次calculated
  2. 由于在取出任务过程中,时间是一直在走的。所以需要减掉已经走过的刻度tick。
  3. 计算需要走多少圈remainingRounds。
  4. 计算实际存入数组索引stopIndex。

后续逻辑太多,将在第二篇中展开,敬请期待。