时间轮剖析

1.什么是时间轮

简单来说,时间轮是一个高效利用线程资源进行批量化调度的调度器。首先把大批量的调度任务全部绑定到同一个调度器上,然后使用这个调度器对所有任务进行管理、触发、以及运行,所以时间轮能高效管理各种延时任务、周期任务、通知任务。

时间轮是以时间作为刻度组成的一个环形队列,所以叫做时间轮。这个环形队列通过一个HashedWheelBucket[]数组来实现,数组的每个元素称为槽,每个槽可以存放一个定时任务列表,叫HashedWheelBucket。HashedWheelBucket是一个双向链表,链表的每个节点表示一个定时任务项HashedWheelTimeout。在HashedWheelTimeout中封装了真正的定时任务TimerTask。

时间轮由多个时间格组成,每个时间格代表当前时间轮的基本时间跨度ticketDuration,其中时间轮的时间格的个数是固定的。

如下图示,有16个时间格(槽),假设每个时间格的单位是1s,那么整个时间轮走完一圈需要16s。每秒钟(即时间格的单位也可以为1ms、1min、1h等)指针会沿着顺时针方向转动一格。通过指针移动来获得每个时间格中的任务列表,然后遍历这个时间格内的双向链表的每个任务并执行,依此循环。

2.工作原理 

时间轮的实质上是参考了生活中的时钟跳动的原理,那么具体是如何实现呢?

在时间轮机制中,有时间槽和时钟轮的概念,时间槽就相当于时钟的刻度;而时钟轮就相当于指针跳动的一个周期,我们可以将每个任务放到对应的时间槽位上。

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

如果时间轮一圈有 20 个槽位,而时钟轮一轮的周期是 200 毫秒,那么我们每个槽位的单位时间就是 10 毫秒,而下一层时间轮的周期就是 4 秒,每个槽位的单位时间也就是 200 毫秒,这就好比秒针与分针, 在秒针周期下, 刻度单位为秒, 在分针周期下, 刻度为分。参考下图更易理解;

3.常见时间轮应用场景 

  • 定时任务调度:时间轮非常适合用于定时任务的调度。通过将任务按照触发时间划分到不同的槽中,可以实现对任务的精确触发和执行。例如,在分布式系统中,可以使用时间轮来实现定时任务的触发和调度。

  • 超时管理:在网络通信或分布式系统中,经常需要管理请求的超时情况。时间轮可以用于管理和处理超时任务。每个槽可以存放一个超时请求,并在达到超时时间时触发相应的操作,例如重新发送请求或进行异常处理。

  • 定时器:时间轮可以用于实现定时器功能。通过将计时任务加入时间轮的相应槽中,可以在预定的时间点触发执行定时任务。定时器广泛应用于各种需求,如批量处理、定时提醒、定时数据刷新等。

  • 调度器:时间轮可用于实现任务调度器。通过将任务根据其优先级划分到不同的槽中,可以实现按优先级顺序触发执行任务。这在一些需要优先处理紧急任务的场景中非常有用。

  • 缓存失效管理:在缓存系统中,需要管理缓存的失效时间。时间轮可以用于管理和处理缓存失效任务。每个槽可以存放一个缓存失效项,并在失效时间到达时触发相应的操作,例如更新缓存或重新加载数据。

通过合理使用时间轮,可以提高系统的任务调度效率和执行精度。

定时任务调度来看时间轮

xxl-job 应该大多数小伙伴都接触过,xxl-job 是一个分布式任务调度平台。其核心设计目标是开发迅速、学习简单、轻量级、易扩展。现已开放源代码并接入多家公司线上产品线,开箱即用。

我们来看看时间轮在 xxl-job 中是如何使用的。

xxl-job中的时间轮

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

没错这行代码就是 xxl-job 中的时间轮,本质就是一个 ConcurrentHashMap,key 为执行的秒数,value 为要执行的 job 的 id列表

那么ConcurrentHashMap中的数据是如何维护,如何管理的呢?

我们来看一下 xxl-job 中是如何做的, JobScheduleHelper 类。

xxl-job 源码
private Thread scheduleThread;

private Thread ringThread;

xxl-job 中用了两个线程:

  • scheduleThread 线程:预读,计算下一次调度时间,过期任务根据配置策略处理,过期5秒内任务放入线程池,未过期任务放入时间轮。

  • ringThread 线程:时间轮调度,时间轮转动触发任务调度

scheduleThread线程

源码有些多,只保留了一些主要的代码。

scheduleThread = new Thread(new Runnable() {
    @Override
    public void run() {
        
        TimeUnit.MILLISECONDS.sleep(5000 - System.currentTimeMillis() % 1000);

        logger.info(">>>>>>>>> init xxl-job admin scheduler success.");

        
        int preReadCount = (XxlJobAdminConfig.getAdminConfig().getTriggerPoolFastMax() + XxlJobAdminConfig.getAdminConfig().getTriggerPoolSlowMax()) * 20;

        while (!scheduleThreadToStop) {

            
            long start = System.currentTimeMillis();

            Connection conn = null;
            Boolean connAutoCommit = null;
            PreparedStatement preparedStatement = null;

            boolean preReadSuc = true;
            
            conn = XxlJobAdminConfig.getAdminConfig().getDataSource().getConnection();
            connAutoCommit = conn.getAutoCommit();
            conn.setAutoCommit(false);

            
            preparedStatement = conn.prepareStatement("select * from xxl_job_lock where lock_name = 'schedule_lock' for update");
            preparedStatement.execute();

            
            long nowTime = System.currentTimeMillis();
            
            List<XxlJobInfo> scheduleList = XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleJobQuery(nowTime + PRE_READ_MS, preReadCount);
            if (scheduleList != null && scheduleList.size() > 0) {
                
                for (XxlJobInfo jobInfo : scheduleList) {

                    if (nowTime > jobInfo.getTriggerNextTime() + PRE_READ_MS) {
                        
                        logger.warn(">>>>>>>>>>> xxl-job, schedule misfire, jobId = " + jobInfo.getId());

                        
                        MisfireStrategyEnum misfireStrategyEnum = MisfireStrategyEnum.match(jobInfo.getMisfireStrategy(), MisfireStrategyEnum.DO_NOTHING);
                        if (MisfireStrategyEnum.FIRE_ONCE_NOW == misfireStrategyEnum) {
                            
                            JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.MISFIRE, -1, null, null, null);
                            logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId());
                        }

                        
                        refreshNextValidTime(jobInfo, new Date());

                    } else if (nowTime > jobInfo.getTriggerNextTime()) {
                        

                        
                        JobTriggerPoolHelper.trigger(jobInfo.getId(), TriggerTypeEnum.CRON, -1, null, null, null);
                        logger.debug(">>>>>>>>>>> xxl-job, schedule push trigger : jobId = " + jobInfo.getId());

                        
                        refreshNextValidTime(jobInfo, new Date());

                        
                        if (jobInfo.getTriggerStatus() == 1 && nowTime + PRE_READ_MS > jobInfo.getTriggerNextTime()) {

                            
                            int ringSecond = (int) ((jobInfo.getTriggerNextTime() / 1000) % 60);

                            
                            pushTimeRing(ringSecond, jobInfo.getId());

                            
                            refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));

                        }

                    } else {
                        

                        
                        int ringSecond = (int) ((jobInfo.getTriggerNextTime() / 1000) % 60);

                        
                        pushTimeRing(ringSecond, jobInfo.getId());

                        
                        refreshNextValidTime(jobInfo, new Date(jobInfo.getTriggerNextTime()));
                    }

                }
                
                for (XxlJobInfo jobInfo : scheduleList) {
                    XxlJobAdminConfig.getAdminConfig().getXxlJobInfoDao().scheduleUpdate(jobInfo);
                }
            } else {
                preReadSuc = false;
            }
            

            long cost = System.currentTimeMillis() - start;

            if (cost < 1000) {  
                
                TimeUnit.MILLISECONDS.sleep((preReadSuc ? 1000 : PRE_READ_MS) - System.currentTimeMillis() % 1000);
            }

        }

        logger.info(">>>>>>>>>>> xxl-job, JobScheduleHelper#scheduleThread stop");
    }
});
scheduleThread.setDaemon(true);
scheduleThread.setName("xxl-job, admin JobScheduleHelper#scheduleThread");
scheduleThread.start();

简单时间轮(Simple Time Wheel):

 简单时间轮由一个环形数组和一个指针组成,每个槽存放一个链表。指针在固定时间间隔内按顺时针方向转动一个槽,触发并执行对应槽的任务。这种时间轮适用于任务触发比较频繁的场景。上文分析的 xxl-job 的时间轮就是这种。

时间刻度问题

现在如果按 1 秒为一个时间刻度,那么一天会有 86400 个刻度,如果添加一个任务 80000 秒后执行,那么其中大部分轮询都是空轮询,而且会浪费内存空间(每个时间刻度都有自己的任务队列)。

那么要怎么解决这个问题呢?

带round时间轮

在每个任务中添加一个 round 属性,时间轮每移动一个时间刻度,遍历任务队列取出 round = 0 的任务执行,然后将其余的任务 round - 1。

解决了内存空间的问题,不需要创建那么多的任务队列,但是每次转动都要扫描任务队列的所有任务,耗时是个问题。

分层时间轮(Hierarchical Time Wheel):

分层时间轮是对简单时间轮的扩展,将时间轮按照不同的精度划分为多个层级(Level)。每个层级的时间轮比前一个层级的时间间隔更大。当低层级的时间轮触发时,在高层级的时间轮中添加对应的任务,实现任务的延迟触发。这种时间轮适用于需要支持较长时间范围的任务调度,同时具有较高的触发精度。任务通过升级和降级来转移队列中的位置。Caffeine中就用到了这种轮。

时间堆(Time Heap): 

时间堆是通过优先队列实现的时间轮。任务按照触发时间的先后顺序插入到优先队列中,每次从队列中取出最近触发的任务执行。新增任务和删除任务的时间复杂度为 O(log n),适用于任务触发比较少且需要精确触发时间的场景。

时间轮与时间堆结合:

有些实现方式会将时间轮和时间堆结合起来使用,以平衡触发精度和时间复杂度。例如,可以使用时间轮作为大范围的调度器,支持快速触发任务;而对于需要更精确触发时间的任务,可以使用时间堆进行管理。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值