浅谈 Time wheel 的实现、原理以及典型场景

本文为稀土掘金技术社区首发签约文章,30天内禁止转载,30天后未获授权禁止转载,侵权必究!

时间轮 (Time Wheel) 是一种高效的定时器数据结构,广泛应用于需要管理大量定时任务的系统中。它通过将时间划分为多个时间槽,并使用环形数组来表示时间轮盘,从而在常数时间复杂度内执行插入、删除和查找操作。时间轮结构不仅提高了定时任务的管理效率,还显著减少了系统资源消耗。笔者期望通过本篇文章,能够向各位读者详细介绍时间轮的实现方法、工作原理以及典型应用场景,期望可以为读者提供一个比较全面的技术参考。

Time wheel 的实现对比

下面是几种 Time Wheel 的实现类型对比

实现方式 描述 实现方法 优点 缺点
简单时间轮(Simple Time Wheel) 最基本的时间轮实现,使用单层环形数组来管理定时任务。 1、环形数组 :使用一个固定大小的数组表示时间轮,每个数组元素表示一个时间槽,存储定时任务的列表。 2、时间槽 :每个时间槽表示一个固定的时间间隔(如1秒)。 3、指针移动:一个指针每隔一个时间间隔移动一次,指向下一个时间槽,并执行该槽中的所有任务。 1、实现简单,适用于短时间跨度的定时任务。 2、插入、删除和查找任务的时间复杂度为 O(1)。 1、时间轮大小固定,难以处理超出时间轮范围的长时间定时任务。 2、适用于任务间隔较短的场景,无法精确到毫秒级。
层级时间轮(Hierarchical Time Wheels) 层级时间轮通过使用多个层级的时间轮来管理不同时间跨度的定时任务。 1、多层时间轮 :使用多个时间轮,每个时间轮负责一个特定的时间跨度(如毫秒、秒、分钟)。 2、任务转移 :当一个时间轮的时间槽满时,将任务移到下一个层级的时间轮中。 3、指针移动:每个时间轮都有自己的指针,按照不同的时间间隔移动。 1、能够处理长时间跨度的定时任务,适用于需要管理不同时间级别的任务。 2、插入、删除和查找任务的时间复杂度仍为 O(1)。 1、实现复杂,需要处理跨层级任务的移动和管理。 2、存储开销较大,每个层级的时间轮都需要分配内存。
Hashed Time Wheel 使用哈希函数将任务分配到不同的时间槽中 1、哈希函数 :使用哈希函数计算任务的到期时间,将任务分配到相应的时间槽中。 2、时间槽 :每个时间槽存储一个任务列表,任务按到期时间排序。 3、指针移动:指针按照固定时间间隔移动,执行指针指向槽中的任务。 1、任务插入和查找的时间复杂度为 O(1)。 2、适用于高并发场景,能够高效管理大量定时任务。 1、实现复杂度较高,需要处理哈希冲突和时间槽的扩展。 2、需要额外的内存来存储哈希表和任务列表。
Distributed Time Wheel 用于分布式系统的时间轮实现,能够在多个节点之间协调定时任务的管理 1、分布式协调 :使用分布式协调服务(如Zookeeper、Consul)来管理时间轮状态和任务分配。 2、任务分片 :将任务分片分配到不同的节点,每个节点负责管理一部分定时任务。 3、指针同步:各节点的时间轮指针需要定期同步,确保任务按时执行。 1、适用于大规模分布式系统,具有高可用性和扩展性。 2、能够处理跨节点的定时任务,提供可靠的任务调度机制。 1、实现复杂度较高,需要处理分布式协调和故障恢复。 2、网络通信和协调开销较大,可能影响性能。

简单时间轮(Simple Time Wheel) 的代码实现和测试

这里笔者提供一个基于 java LinkedList 一个简单实现,来帮助理解 Time Wheel 的基本思路,完整代码如下:

java 复制代码
package com.glmapper.wheeltimer;

import java.util.LinkedList;
import java.util.List;

/**
 * @author glmapper
 * @date 2024/7/10
 * @description 时间轮
 * @class SimpleTimeWheel
 */
public class SimpleTimeWheel {
    // 时间轮大小
    private final int wheelSize;
    // 时间轮
    private final List<Task>[] wheel;
    // 当前指针
    private int currentIndex;

    public SimpleTimeWheel(int wheelSize) {
        this.wheelSize = wheelSize;
        this.wheel = new LinkedList[wheelSize];
        for (int i = 0; i < wheelSize; i++) {
            wheel[i] = new LinkedList<>();
        }
        this.currentIndex = 0;
    }

    /**
     * 添加任务
     *
     * @param task  任务
     * @param delay 延迟时间
     */
    public void addTask(SimpleTimeWheel.Task task, int delay) {
        // 计算任务应该放到哪个槽
        int index = (currentIndex + delay) % wheelSize;
        wheel[index].add(task);
    }

    /**
     * 时间轮转动
     */
    public void tick() {
        List<Task> tasks = wheel[currentIndex];
        for (Task task : tasks) {
            task.execute();
        }
        tasks.clear();
        currentIndex = (currentIndex + 1) % wheelSize;
    }

    public static class Task {
        private final String name;

        public Task(String name) {
            this.name = name;
        }

        public void execute() {
            System.out.println("Executing: " + name);
        }
    }
}

测试代码:

java 复制代码
public static void main(String[] args) throws InterruptedException {
        SimpleTimeWheel timeWheel = new SimpleTimeWheel(60);
        timeWheel.addTask(new Task("Task 1"), 5);
        timeWheel.addTask(new Task("Task 2"), 10);
	// 这里通过一个 loop 来不断的从 timeWheel 中捞取待执行的任务,这里的精度是 1 s
        while (true) {
            timeWheel.tick();
            // for test
            Thread.sleep(1000);
        }
    }

实现比较简单,因此它能支撑的场景也就比较局限;首先是精度问题,如果我们使用毫秒级的精度,则可能轮的划分太细,导致任务列表离散程度较大;如果轮的范围较小,则单个索引节点上的任务数也可能会非常多。因此为了能够实现对更加精细的控制,就衍生出了使用多个层级的时间轮来管理不同时间跨度的定时任务。

层级时间轮(Hierarchical Time Wheels)的代码实现和测试

下面是一个层次时间轮的实现,按照小时、分钟以及秒三个精度进行分层。

java 复制代码
package com.glmapper.wheeltimer;

import java.util.LinkedList;
import java.util.List;

/**
 * @Classname HierarchicalTimeWheel
 * @Description 层次时间轮
 * @Date 2024/7/12 17:47
 * @Created by glmapper
 */
public class HierarchicalTimeWheel {
    // 秒级时间轮
    private final TimeWheel secondsWheel;
    // 分钟级时间轮
    private final TimeWheel minutesWheel;
    //  小时级时间轮
    private final TimeWheel hoursWheel;

    public HierarchicalTimeWheel() {
        secondsWheel = new TimeWheel(60, 1);
        minutesWheel = new TimeWheel(60, 60);
        hoursWheel = new TimeWheel(24, 3600);
    }

    public void addTask(Task task, int delay) {
        if (!secondsWheel.addTask(task, delay)) {
            if (!minutesWheel.addTask(task, delay)) {
                hoursWheel.addTask(task, delay);
            }
        }
    }

    public void tick() {
        List<Task> secondsTasks = secondsWheel.tick();
        executeTasks(secondsTasks);

        if (secondsWheel.currentIndex == 0) {
            List<Task> minutesTasks = minutesWheel.tick();
            executeTasks(minutesTasks);

            if (minutesWheel.currentIndex == 0) {
                List<Task> hoursTasks = hoursWheel.tick();
                executeTasks(hoursTasks);
            }
        }
    }

    private void executeTasks(List<Task> tasks) {
        for (Task task : tasks) {
            task.execute();
        }
        tasks.clear();
    }

    public boolean hasTasks() {
        return secondsWheel.hasTasks() || minutesWheel.hasTasks() || hoursWheel.hasTasks();
    }
  
  public static class Task {
        private String name;

        public Task(String name) {
            this.name = name;
        }

        public void execute() {
            System.out.println(new SimpleDateFormat("yyyy.MM.dd HH:mm:ss").format(new Date()) + " Executing: " + name);
        }

        @Override
        public String toString() {
            return name;
        }
    }

    public static class TimeWheel {
        private final int wheelSize;
        private final List<Task>[] wheel;
        private int currentIndex;
        private final int interval;

        public TimeWheel(int wheelSize, int interval) {
            this.wheelSize = wheelSize;
            this.interval = interval;
            this.wheel = new LinkedList[wheelSize];
            for (int i = 0; i < wheelSize; i++) {
                wheel[i] = new LinkedList<>();
            }
            this.currentIndex = 0;
        }

        public boolean addTask(Task task, int delay) {
            int index = (currentIndex + delay / interval) % wheelSize;
            if (delay < wheelSize * interval) {
                wheel[index].add(task);
                return true;
            }
            return false;
        }

        public List<Task> tick() {
            List<Task> tasks = wheel[currentIndex];
            currentIndex = (currentIndex + 1) % wheelSize;
            return tasks;
        }

        public boolean hasTasks() {
            for (List<Task> tasks : wheel) {
                if (!tasks.isEmpty()) {
                    return true;
                }
            }
            return false;
        }
    }
}

测试代码:

java 复制代码
public static void main(String[] args) throws InterruptedException {
        HierarchicalTimeWheel timeWheel = new HierarchicalTimeWheel();
        timeWheel.addTask(new Task("Task 1"), 5);
        timeWheel.addTask(new Task("Task 2"), 65);
        timeWheel.addTask(new Task("Task 3"), 3605);
        while (timeWheel.hasTasks()) {
            timeWheel.tick();
            Thread.sleep(1000);
        }
}

执行日志:

yaml 复制代码
2024.07.18 14:25:47 Executing: Task 1
2024.07.18 14:28:40 Executing: Task 2 # 因为 debug 原因,时间有一些误差
...

层次时间轮的实现主要包括几个核心类:

  • Task 类:表示需要执行的任务,包含任务名称和执行方法。

  • TimeWheel 类:表示一个时间轮,包含时间槽数组、当前指针位置和时间间隔。主要方法包括添加任务(addTask)、时间的一次跳动最小单位(tick)和检查是否有任务(hasTasks)。

  • HierarchicalTimeWheel 类:表示层级时间轮,包含秒轮、分轮和小时轮。主要方法包括添加任务(addTask)、时间的一次跳动最小单位(tick)和检查是否有任务(hasTasks)。在每个时间轮的 tick 方法中,检查是否需要向上级时间轮传递任务。

执行逻辑如下:

  • 1、添加任务时,首先尝试添加到秒轮,如果秒轮满了,尝试添加到分轮,如果分轮也满了,最后添加到小时轮。

  • 2、每秒钟调用一次 tick 方法,首先执行秒轮中的任务,如果秒轮指针转到 0,说明一分钟过去了,执行分轮中的任务;如果分轮指针转到 0,说明一小时过去了,执行小时轮中的任务。

  • 3、任务到期时,调用任务的 execute 方法执行任务。

多层级的时间轮实现在精度控制上会更加合理,但相对应的是它需要额外的内存空间来支撑每个层的任务存储。在具体的工程化实践中,笔者上述提供的代码还需要很多的优化,比如处理跨层级任务的移动和管理等。

Hashed Time Wheel - Netty 提供的实现

HashedWheelTimer 本质是一种类似延迟任务队列的实现,适用于对时效性不高的,可快速执行的,大量的"小"任务,能够做到高性能,低消耗。在众多时间轮的实现中,目前 netty 中提供的 Hashed Time Wheel 是应用较多的。Netty 中提供 Time Wheel 的主要背景是 Netty 需要管理大量的连接,包括发送超时、心跳检测等等;试想一下,如果为每个链接维护一个 Timer 定时器的话,这将耗费多大大量的资源?

下面是 HashedWheelTimer 的一个小 case:

java 复制代码
public static void main(String[] args) throws InterruptedException {
    HashedWheelTimer wheelTimer = new HashedWheelTimer();
    wheelTimer.newTimeout(timeout -> System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + " 1s delay"), 1, TimeUnit.SECONDS);
    wheelTimer.newTimeout(timeout -> System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + " 10s delay"), 10, TimeUnit.SECONDS);
    wheelTimer.newTimeout(timeout -> System.out.println(new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + " 15s delay"), 15, TimeUnit.SECONDS);
    TimeUnit.SECONDS.sleep(20);
}

输出结果

arduino 复制代码
2024-07-18 15:40:33 1s delay
2024-07-18 15:40:42 10s delay
2024-07-18 15:40:47 15s delay

理解 Timer、TimerTask 和 Timeout

  • 1、Timer 是一个定时器的接口,定义了管理定时任务的基本方法。Netty 中 HashedWheelTimerTimer 接口的具体实现。Timer 接口主要有以下方法:

    • newTimeout(TimerTask task, long delay, TimeUnit unit):创建一个新的定时任务。
    • stop():停止定时器,并返回所有未到期的任务。
  • 2、TimerTask 表示一个定时任务。主要包括一个 run 方法,用来接受一个 Timeout 任务。

    • run(Timeout timeout):当任务到期时执行的逻辑。Timeout 参数用于在任务内部获取更多信息和控制。
  • 3、Timeout 表示一个特定的定时任务,它可以取消任务并查询任务状态。主要方法有:

    • Timer timer():返回创建此 TimeoutTimer

    • TimerTask task():返回关联的 TimerTask

    • boolean cancel():取消此定时任务,如果任务已经执行或取消则返回 false

    • boolean isExpired():检查任务是否已到期。

    • boolean isCancelled():检查任务是否已被取消。

当调用 TimernewTimeout 方法时,会创建一个新的 Timeout 实例,并将其与一个 TimerTask 关联。newTimeout 方法需要传递一个 TimerTask 实例、延迟时间和时间单位。当定时器触发时,HashedWheelTimer 会调用 Timeout 对象中关联的 TimerTaskrun 方法,执行定时任务的逻辑。从任务管理的角度,主要是由 Timeout 自己负责,如取消任务(cancel)和检查任务状态(isExpiredisCancelled),Timer 对象管理整个时间轮和所有的定时任务。

理解 timeouts 队列和 cancelledTimeouts 队列

java 复制代码
public class HashedWheelTimer implements Timer {
    // ...
    private final Queue<HashedWheelTimeout> timeouts = PlatformDependent.newMpscQueue();
    private final Queue<HashedWheelTimeout> cancelledTimeouts = PlatformDependent.newMpscQueue();
    // ...
}

MpscQueue 全称 Multi-Producer Single-Consumer Queue,是一种适合于多个生产者,单个消费者的高并发场景的高性能的、无锁的队列。timeoutscancelledTimeouts 都使用了这个结构。

  • timeouts: 用于存储所有新提交的定时任务
  • cancelledTimeouts:用于存储所有被取消的定时任务。当你调用 Timeout.cancel() 方法取消一个定时任务时,该任务会被加入到 cancelledTimeouts 队列中

从场景来看,MpscQueue 这种结构比较适合时间轮底层任务的存储。首先是多生产者特性,任务的添加在实际的应用中一定是存在多个线程同时提交任务的,前面提到的无锁机制,实际上就是在做入队时使用了 CAS 机制;另一个是单消费者特性,在 netty 的实现中,HashedWheelTimer 内部有一个单一的线程(Worker 线程)负责处理所有定时任务。因此这也限定了 HashedWheelTimer 在一些长任务上的发挥,因为其中的任务是串行执行的

Hashed Time Wheel 应用分析

这里我们以蚂蚁集团开源的 sofabolt 组件中的心跳机制来分析一下 HashedWheelTimer 的应用。sofabolt 中维护了一个 HashedWheelTimer 实例,代码如下:

java 复制代码
public class TimerHolder {
    private final static long defaultTickDuration = 10;
    // 维护一个单例
    private static class DefaultInstance {
        static final Timer INSTANCE = new HashedWheelTimer(new NamedThreadFactory(
                                        "DefaultTimer" + defaultTickDuration, true),
                                        defaultTickDuration, TimeUnit.MILLISECONDS);
    }

    private TimerHolder() {
    }

    /**
     * Get a singleton instance of {@link Timer}. <br>
     * The tick duration is {@link #defaultTickDuration}.
     * 
     * @return Timer
     */
    public static Timer getTimer() {
        return DefaultInstance.INSTANCE;
    }
}

SOFABolt 心跳相关的处理有两部分:客户端发送心跳,服务端接收心跳处理并返回响应。客户端发送代码的逻辑如下(相关代码做了删减,主要是整体逻辑的展示):

java 复制代码
@Override
public void heartbeatTriggered(final ChannelHandlerContext ctx) throws Exception {
    Integer heartbeatTimes = ctx.channel().attr(Connection.HEARTBEAT_COUNT).get();
    final Connection conn = ctx.channel().attr(Connection.CONNECTION).get();
    // 如果心跳次数超过最大次数,关闭连接
    if (heartbeatTimes >= maxCount) {
       // 如果心跳次数超过最大次数,关闭连接
    } else {
        boolean heartbeatSwitch = ctx.channel().attr(Connection.HEARTBEAT_SWITCH).get();
        if (!heartbeatSwitch) {
            return;
        }
        final HeartbeatCommand heartbeat = new HeartbeatCommand();
        // 创建本次请求的 InvokeFuture 对象 
        final InvokeFuture future = new DefaultInvokeFuture(heartbeat.getId(),
            new InvokeCallbackListener() {
               // 省略
            }, null, heartbeat.getProtocolCode().getFirstByte(), this.commandFactory);
        final int heartbeatId = heartbeat.getId();
        // 将 InvokeFuture 对象加入到当前的 Connection 中
        conn.addInvokeFuture(future);
        // 发送心跳
        ctx.writeAndFlush(heartbeat).addListener(new ChannelFutureListener() {
          // ...
        });
        // 设置心跳超时
        TimerHolder.getTimer().newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                InvokeFuture future = conn.removeInvokeFuture(heartbeatId);
                if (future != null) {
                    future.putResponse(commandFactory.createTimeoutResponse(conn
                        .getRemoteAddress()));
                    future.tryAsyncExecuteInvokeCallbackAbnormally();
                }
            }
        }, heartbeatTimeoutMillis, TimeUnit.MILLISECONDS);
    }

}

当客户端接收到 IdleStateEvent 时会调用上面的 heartbeatTriggered 方法。 在 Connection 对象上会维护心跳失败的次数,当心跳失败的次数超过系统的最大次时,主动关闭 Connection。如果心跳成功则清除心跳失败的计数。同样的,在心跳的超时处理中同样使用 Netty 的 Timer 实现来管理超时任务(和请求的超时管理使用的是同一个Timer实例)。

RpcHeartbeatProcessor 是SOFABolt对心跳处理的实现,包含对心跳请求的处理和心跳响应的处理(服务端和客户端复用这个类,通过请求的数据类型来判断是心跳请求还是心跳响应)。

如果接收到的是一个心跳请求,则直接写回一个 HeartbeatAckCommand(心跳响应)。如果接收到的是来自服务端的心跳响应,则从 Connection 取出 InvokeFuture 对象并做对应的状态变更和其他逻辑的处理:取消超时任务、执行 Callback 。如果无法从 Connection 获取 InvokeFuture 对象,则说明客户端已经判定心跳请求超时。

总结

本篇主要介绍了几种不同时间轮的实现及优缺点对比,并给出了简单时间轮和层级时间轮的简单代码实现,以帮助读者理解相关概念。此外笔者基于 Netty 中的 HashedWheelTimer 实现进行了一些概念的解释,并简单分析了 HashedWheelTimer 在应用场景上的一些局限性;最后基于 sofabolt 的心跳机制介绍了 HashedWheelTimer 的一个具体的��用案例,以期望读者可以加深印象。

总的来说,时间轮作为一种处理延迟任务的结构,它可以在较低的内存消耗下,实现准实时的延迟任务管理和执行,从简单时间轮、层次时间轮或者是hash 时间轮的实现机制来看,一般是基于数组+链表的方式实现,因此整体在任务添加和任务调度上的时间复杂度也控制的不错。

参考

相关推荐
络710 分钟前
Spring01——Spring简介、Spring Framework架构、Spring核心概念、IOC入门案例、DI入门案例
java·后端·spring
醉颜凉11 分钟前
Eureka:Spring Cloud中的服务注册与发现如何实现?
java·后端·spring·spring cloud·面试·eureka·微服务架构
一只IT攻城狮32 分钟前
idea配置连接数据库的操作方法(适配不同版本)
java·数据库·后端·mysql·intellij-idea
阿东日志2 小时前
Redis高级---面试总结之内存过期策略及其淘汰策略
数据库·redis·缓存·面试
J老熊2 小时前
Redis持久化方式、常见问题及解决方案
java·数据库·redis·面试·系统架构
编程乐趣3 小时前
Scriban:高效、强大的.NET开源模板引擎,可用于邮件、文档生成!
后端·c#·asp.net·.net
ChinaRainbowSea4 小时前
五,Spring Boot中的 Spring initializr 的使用
java·spring boot·后端·spring·log4j·web
1.01^10004 小时前
[000-01-015].第03节:SpringBoot中数据源的自动配置
java·spring boot·后端
虫小宝4 小时前
利用Spring Boot实现微服务的链路追踪
spring boot·后端·微服务
ForeverRover4 小时前
Spring Framework P4
java·后端·spring