【Netty】Netty时间轮实践与源码解析

目录

定时任务

本篇来聊聊定时与时间轮,对于一个应用来说,其实很多场景需要采用定时任务进行执行,比如每天定时发送统计报表的数据,金融支付中的对账文件推送,除了业务场景中,在客户端服务端常连接的心跳检测、延时任务执行中其实都需要定时任务进行支持。JDK本身提供了几种定时任务,但是netty进一步优化了,那么本篇带着这三个问题聊聊

  • Jdk原生方案有哪些不满足的地方
  • Netty为什么采用时间轮算法来实现
  • netty时间轮算法流程源码解析

其实定时任务有三种方式,固定周期执行 (每天9点执行)、延迟一定时间执行(5S后执行)、执行某个时间执行(1月20号 10点执行)

JDK定时任务

Timer

具体执行就是集成TimerTask任务,覆盖run方法,其实就是另外启动一个线程。

java 复制代码
public class TestTimer extends TimerTask{

    public static void main(String[] args) {
        Timer timer = new Timer();
        timer.schedule(new TestTimer(),6000); // 6S后执行
    }

    @Override
    public void run() {
        System.out.println(Thread.currentThread().getName()+"\t timer");
    }
}

原理

在构造方法的时候 创建一个线程。而这个线程会从queue中获取对应的任务执行,

java 复制代码
	private final TaskQueue queue = new TaskQueue(); // 小根堆
	private final TimerThread thread = new TimerThread(queue);
    
    public Timer(String name) {
        thread.setName(name);
        thread.start();
    }

TaskQueue是一个数组实现的小根堆,越快deadline执行的任务,越在根部,所以执行一个任务的之间复杂度是O(1) 但是插入一个任务的时间复杂度是O(LogN)

ScheduledThreadPoolExecutor

上面的Timer其实存在一定设计的缺陷

  • Timer是单线程模型,如果一个任务执行太久,可能影响后面任务的执行。
  • TimerTask 如果执行出现异常,Timer 并不会捕获,会导致线程终止,其他任务永远不会执行。
java 复制代码
   ScheduledExecutorService exthreadPool = Executors.newScheduledThreadPool(5);
    exthreadPool.scheduleAtFixedRate(() ->
            System.out.println("HelloWorld"),
            1000, // 延迟1S后开始执行
            2000,  // 2S执行一次
            TimeUnit.MILLISECONDS);

可以发现上述其实都有 执行任务的线程,添加任务的主线程、封装任务的任务。但是由于其本身数据结构的复杂度是O(N) , 所以在海量任务执行的场景中,其实性能和效率并不高。

时间轮算法

时间轮就是一个环形队列,类似于时钟的结构,所以叫时间轮。

如图所示,将时间轮划分为16个格子,每1S执行一个格子的任务。而每个格子的都是一个链表结构,通过指针进行连接所要执行的任务。

比如当前要执行一个1S后执行的任务,就可以根据当前指针所指的位置,添加到对应的3位置,但是如果要执行的是26S后的位置,那么如何计算的呢。其实很简单就是 26%15 = 11, 也就是一轮之后的第11个位置。

相比于JDK提供的定时任务,时间轮算法增加、删除任务的时间复杂度都是O(1) ,而时间轮算法是一种思想,具体的落地在Netty、Kafka、Dubbo中都有对应的具体实现。

netty时间轮架构


整体架构其实由三部分组成

  • HashedWheelBucket 数组 构建成一个时间轮结构
    • 每个格子 其实就是一个HashedWheelBucket对象,内部持有head、tail节点 分别指向 HashedWheelTimeout 具体的任务
  • HashedWheelTimeout 封装具体执行的任务
  • Woker 执行具体的任务

netty时间轮 源码解析

基本使用

java 复制代码
public class TestHashWheelTimer {

    public static void main(String[] args) {
        // tickDuration : 时间间隔
        // TimeUnit : 单位
        // ticksPerWheel : 划分多少块  默认512
        HashedWheelTimer hashedWheelTimer = new HashedWheelTimer(1, TimeUnit.SECONDS, 20);
        hashedWheelTimer.newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                System.out.println(Thread.currentThread().getName()+"\t hello");
            }
        }, 1, TimeUnit.SECONDS);
    }
}

其实主要就是初始化时间轮、添加任务(之后执行任务)

HashedWheelTimer 初始化

    1. 先进行参数的有效检查
    1. 创建时间轮,以及计算 & 运算的掩码
    1. 创建执行任务的线程
    1. 异常边界处理
java 复制代码
	// threadFactory 线程工厂 构建woker线程的
    // tickDuration 时间间隔
    // ticksPerWheel 时间轮的槽
    public HashedWheelTimer(
            ThreadFactory threadFactory,
            long tickDuration, TimeUnit unit, int ticksPerWheel, boolean leakDetection,
            long maxPendingTimeouts, Executor taskExecutor) {

        // 有效性检查
        checkNotNull(threadFactory, "threadFactory");
        checkNotNull(unit, "unit");
        checkPositive(tickDuration, "tickDuration");
        checkPositive(ticksPerWheel, "ticksPerWheel");
        this.taskExecutor = checkNotNull(taskExecutor, "taskExecutor");
        
        // 核心 ⭐️
        wheel = createWheel(ticksPerWheel);
        // 掩码 计算任务  应该存放在时间轮的具体位置 & 替换 % 运算
        mask = wheel.length - 1;

        // 转换成纳秒 10^6 毫秒 秒
        long duration = unit.toNanos(tickDuration);

        // 创建woker线程
        workerThread = threadFactory.newThread(worker);

        // 指定最大延时任务有多少
        this.maxPendingTimeouts = maxPendingTimeouts;

        // 控制HashedWheelTimer的实例 不能超过64
        if (INSTANCE_COUNTER.incrementAndGet() > INSTANCE_COUNT_LIMIT &&
            WARNED_TOO_MANY_INSTANCES.compareAndSet(false, true)) {
            reportTooManyInstances();
        }
    }

createWheel 创建HashedWheelBucket数组

  • 1.这里其实就是判断传入的值,如果是2的幂次 直接使用,否则找到第一个大于该数的2的次幂,比如当前传入的是15,那么就会转换成16,至于为什么要进行这样处理,这样的方式 可以利用 i & (length - 1) 进行高效的位运算。而前提必须是2的次幂。
  • 2.创建时间轮数组 并且初始化每个单独时间轮,需要注意的是 HashedWheelBucket中包含头和尾部节点,通过双向链表的方式
java 复制代码
    private static HashedWheelBucket[] createWheel(int ticksPerWheel) {
        // 调整成2的次幂
        // 目的: 位运算 替换 %的 运算 效率高
        ticksPerWheel = MathUtil.findNextPositivePowerOfTwo(ticksPerWheel);

        // 初始化数组 HashedWheelBucket
        HashedWheelBucket[] wheel = new HashedWheelBucket[ticksPerWheel];
        for (int i = 0; i < wheel.length; i ++) {
            wheel[i] = new HashedWheelBucket();
        }
        return wheel;
    }

newTimeout 添加任务

  • 1.先判断任务和时间单位是否为空
  • 2.延时任务+1 以及判断是否超过阈值
  • 3.启动执行任务的核心线程
  • 4.将任务封装进 HashedWheelTimeout 并且添加到timeouts队列中
java 复制代码
    public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
        // 判断处理
        checkNotNull(task, "task");
        checkNotNull(unit, "unit");

        long pendingTimeoutsCount = pendingTimeouts.incrementAndGet();

        // 超越延时任务的最大值
        // 控制一个HashedWheelTimer中的 延时任务的个数
        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 + ")");
        }

        // 核心 ⭐️ 启动woker线程
        start();

        // 当前时间+延时时间 - 开启时间
        long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;

        // Guard against overflow.
        if (delay > 0 && deadline < 0) {
            deadline = Long.MAX_VALUE;
        }
        // 创建任务 封装 HashedWheelTimeout
        HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
        // 添加到队列中 不会立即加入到Bucked 加入到一个Mpsc的队列中 无所队列 并发效率比较高
        timeouts.add(timeout);
        return timeout;
    }

执行任务

启动的时候,其实先启动woker线程,然后会判断启动时间starTime =0 会使用CountDownLatch进行Wait()

java 复制代码
    public void start() {
        // 线程启动
        // 判断线程状态
        switch (WORKER_STATE_UPDATER.get(this)) {
            case WORKER_STATE_INIT:
                if (WORKER_STATE_UPDATER.compareAndSet(this, WORKER_STATE_INIT, WORKER_STATE_STARTED)) {
                    // 启动线程 只有init才能启动
                    workerThread.start();
                }
                break;
        }
        
        // 线程不一定马上启动  会进行等待
        while (startTime == 0) {
            try {
                // 线程启动完成后, woker线程进行等待唤醒
                // 后续会进行唤醒
                startTimeInitialized.await();
            } 
        }
    }
  • 1.启动线程,然后赋值startTime的值 countDown() 上面的步骤就可以执行了。
  • 2.等到下一批次要执行的时间,如果不到就sleep
  • 3.计算当前任务轮下标,取出对应时间轮的链表
  • 4.从队列中获取10W个任务,然后添加到对应的位置
  • 5.从链表中获取任务进行执行。
java 复制代码
        public void run() {
            startTime = System.nanoTime();
            if (startTime == 0) {
                 startTime = 1;
            }

            startTimeInitialized.countDown();

            do {
                final long deadline = waitForNextTick();
                if (deadline > 0) {
                    // % 操作
                    int idx = (int) (tick & mask);
                    processCancelledTasks();
                    // 获取对应时间轮中的链表
                    HashedWheelBucket bucket =
                            wheel[idx];
                    transferTimeoutsToBuckets();
                    //
                    bucket.expireTimeouts(deadline);
                    tick++;
                }
                // worker 如果一直启动 就一直循环
            } while (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_STARTED);

            for (HashedWheelBucket bucket: wheel) {
                bucket.clearTimeouts(unprocessedTimeouts);
            }
            for (;;) {
                HashedWheelTimeout timeout = timeouts.poll();
                if (timeout == null) {
                    break;
                }
                if (!timeout.isCancelled()) {
                    unprocessedTimeouts.add(timeout);
                }
            }
            processCancelledTasks();
        }
java 复制代码
        private void transferTimeoutsToBuckets() {
            // 先取10W个任务 不关心任务在时间轮的格子
            for (int i = 0; i < 100000; i++) {
                HashedWheelTimeout timeout = timeouts.poll();
                if (timeout == null) {
                    break;
                }
                if (timeout.state() == HashedWheelTimeout.ST_CANCELLED) {
                    continue;
                }

                // 结束时间 / 时间轮的个数  还有几格需要执行
                long calculated = timeout.deadline / tickDuration;
                // 获取几轮执行
                timeout.remainingRounds = (calculated - tick) / wheel.length;

                final long ticks = Math.max(calculated, tick);
                int stopIndex = (int) (ticks & mask);

                HashedWheelBucket bucket = wheel[stopIndex];
                // 存储在对应的格子中 链表结构
                bucket.addTimeout(timeout);
            }
        }
java 复制代码
        public void expireTimeouts(long deadline) {
            HashedWheelTimeout timeout = head;

            while (timeout != null) {
                HashedWheelTimeout next = timeout.next;
                if (timeout.remainingRounds <= 0) {
                    next = remove(timeout);
                    // 保证当前链表中,过期任务也可以执行
                    // deadline 当前时间 远远大于timeOut的deadline
                    // 这个timerout已经过期了 会执行
                    if (timeout.deadline <= deadline) {
                        // 执行
                        timeout.expire();
                    } else {
                        throw new IllegalStateException(String.format(
                                "timeout.deadline (%d) > deadline (%d)", timeout.deadline, deadline));
                    }
                } else if (timeout.isCancelled()) {
                    next = remove(timeout);
                } else {
                    timeout.remainingRounds --;
                }
                timeout = next;
            }
        }

时间轮的优缺点

优点

  • 高效的插入和过期检查:插入的时间复杂度O(1) 因为是直接定位到对应的格子以及链表操作
  • 可配置的时间粒度:时间轮的槽数量可以可配置
  • 处理大量定时任务:比较适合处理大量定时任务的场景,超时监测。

缺点

  • 任务延迟执行:执行的时间可能不精确,会延后。
  • 极端情况的空推进,A任务1S后执行,B任务6小时后执行,中间这段时间就是空推进。这里Kafka使用了多级时间轮的方式进行解决。

品一品优秀设计

1.利用CountDownLatch锁

巧妙使用CountDownLatch 进行等待线程启动后 才执行后面的任务添加。

2.MPSC的应用

性能优化&线程安全,使用MPSC可以多生产者安全添加任务,单消费者消费,既减少了并发竞争也提高的了性能。

实际的生产环境选择

ScheduledThreadPoolExecutor和HashedWheelTimer 各有优劣,需要根据使用场景进行权衡

  • 关注任务调度的及时性:选择ScheduledThreadPoolExecutor
  • 存在大量调度任务:选择HashedWheelTimer
相关推荐
AIAdvocate5 分钟前
Pandas_数据结构详解
数据结构·python·pandas
小言从不摸鱼6 分钟前
【AI大模型】ChatGPT模型原理介绍(下)
人工智能·python·深度学习·机器学习·自然语言处理·chatgpt
C-SDN花园GGbond36 分钟前
【探索数据结构与算法】插入排序:原理、实现与分析(图文详解)
c语言·开发语言·数据结构·排序算法
迷迭所归处2 小时前
C++ —— 关于vector
开发语言·c++·算法
架构文摘JGWZ2 小时前
Java 23 的12 个新特性!!
java·开发语言·学习
FreakStudio2 小时前
全网最适合入门的面向对象编程教程:50 Python函数方法与接口-接口和抽象基类
python·嵌入式·面向对象·电子diy
leon6252 小时前
优化算法(一)—遗传算法(Genetic Algorithm)附MATLAB程序
开发语言·算法·matlab
拾光师3 小时前
spring获取当前request
java·后端·spring
aPurpleBerry3 小时前
neo4j安装启动教程+对应的jdk配置
java·neo4j
锦亦之22333 小时前
QT+OSG+OSG-earth如何在窗口显示一个地球
开发语言·qt