netty⑤ 源码 - HashedWheelTimer

150 阅读10分钟

1. ScheduledThreadPoolExecutor、DelayedQueue、Timer

ScheduledThreadPoolExecutor

//new ScheduledThreadPoolExecutor(corePoolSize)
ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(5);
scheduledExecutorService.schedule(()->{ System.out.println("hello world3");
}, 3,  TimeUnit.SECONDS);
scheduledExecutorService.schedule(()->{ System.out.println("hello world1");
}, 1,  TimeUnit.SECONDS);
scheduledExecutorService.schedule(()->{ System.out.println("hello world2");
}, 2,  TimeUnit.SECONDS);

ScheduledThreadPoolExecutor 继承于 ThreadPoolExecutor,添加任务时将Runnable包装成ScheduledFutureTask并存入DelayedWorkQueue 。ScheduledFutureTask 继承于 FutureTask,并重写了 run() 方法,其具备周期执行任务的能力。DelayedWorkQueue 内部类似优先级队列 PriorityQueue,不过内部的ScheduledFutureTask同时还记录了在heap array中的index(PriorityQueue取消任务时需要遍历找到index),deadline 最近的任务在队列头部。对于周期执行的任务,在执行完会重新设置时间,并再次放入队列中。 基于小根堆的结构,使得 新增和取消任务的时间复杂度都是 O(logn)。

RunnableScheduledFuture<?>[] queue =
            new RunnableScheduledFuture<?>[INITIAL_CAPACITY];

private void siftUp(int k, RunnableScheduledFuture<?> key) {
    while (k > 0) {
        int parent = (k - 1) >>> 1;
        RunnableScheduledFuture<?> e = queue[parent];
        if (key.compareTo(e) >= 0)
            break;
        queue[k] = e;
        setIndex(e, k);
        k = parent;
    }
    queue[k] = key;
    setIndex(key, k);
}

private void siftDown(int k, RunnableScheduledFuture<?> key) {
    int half = size >>> 1;
    while (k < half) {
        int child = (k << 1) + 1;
        RunnableScheduledFuture<?> c = queue[child];
        int right = child + 1;
        if (right < size && c.compareTo(queue[right]) > 0)
            c = queue[child = right];
        if (key.compareTo(c) <= 0)
            break;
        queue[k] = c;
        setIndex(c, k);
        k = child;
    }
    queue[k] = key;
    setIndex(key, k);
} 

DelayedQueue

如果想实现自己的定时机制,还可以直接使用DelayedQueue,搭配异步线程使用。其内部是采用优先级队列 PriorityQueue 存储对象。DelayQueue 中的每个对象必须实现 Delayed 接口,并重写 compareTo 和 getDelay 方法

public static void main(String[] args) throws Exception {
	BlockingQueue<SampleTask> delayQueue = new DelayQueue<>();
	long now = System.currentTimeMillis();
	delayQueue.put(new SampleTask(now + 1000));
	delayQueue.put(new SampleTask(now + 20000));
	delayQueue.put(new SampleTask(now + 3000));
	for (int i = 0; i < 3; i++) {
		SampleTask poll = delayQueue.poll(2000, TimeUnit.MILLISECONDS);
		if(poll != null){
			System.out.println(new Date(poll.getTime()));
		}
	}
}

static class SampleTask implements Delayed {
	long time;
	public SampleTask(long time) {
		this.time = time;
	}
	public long getTime() {
		return time;
	}
	@Override
	public int compareTo(Delayed o) {
		return Long.compare(this.getDelay(TimeUnit.MILLISECONDS), o.getDelay(TimeUnit.MILLISECONDS));
	}
	@Override
	public long getDelay(TimeUnit unit) {
		return unit.convert(time - System.currentTimeMillis(), TimeUnit.MILLISECONDS);
	}
}

Timer

也可以简单点直接使用Timer

public static void main(String[] args) {
        AtomicInteger atomicInteger = new AtomicInteger(-1);
        Timer timer = new Timer();
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                System.out.println("task1" + new Date());
            }
        }, 1000, 1000);
        timer.schedule(new TimerTask() {
            @Override
            public void run() {
                int i = 1 / atomicInteger.getAndIncrement();
                System.out.println("task2" + new Date());
            }
        }, 1000, 2000);
    }

task1Mon Jul 11 10:34:26 CST 2022
task2Mon Jul 11 10:34:26 CST 2022
task1Mon Jul 11 10:34:27 CST 2022
Exception in thread "Timer-0" java.lang.ArithmeticException: / by zero
	at com.example.nettydemo.demo1.concurrent.TimerTest$2.run(TimerTest.java:21)
	at java.util.TimerThread.mainLoop(Timer.java:555)
	at java.util.TimerThread.run(Timer.java:505)

其内部启动了一个 TimerThread 异步线程。队列也是由数组结构实现的小根堆。 异常不会被捕获,会影响到其他任务的执行,且线程直接终止了。

以上几种关于定时任务的操作,新增和取消任务的时间复杂度都是 O(logn),面对海量任务场景,都会遇到比较严重的性能瓶颈,接下来 我们看下时间轮是怎么解决这个问题的。

HashedWheelTimer

网上关于时间轮的说法很多,这里懒得画图了,简单总结下

image.png

时间轮就是类似钟表的一个环形结构,有固定的时间间隔和槽位。所有提交的任务都会根据deadLine分配到各个槽位中,但不是时间走到相应的槽位时,里面的任务都会被执行。每个任务分配到槽位时都是具备round属性,就像每分钟,乃至每小时、每天,秒钟都会走到当前6的位置,但不代表只要走到6 ,所有在6的槽位内的任务都需要被执行。 所以时间轮的粗略的逻辑可以分为两块

  1. 将任务分配至槽位
  2. 取出槽位的任务,判断是否需要执行。 使用时间轮的好处是,任务的新增和取消都是 O(1) 时间复杂度,而且只需要一个线程就可以驱动时间轮进行工作

示例

public static void main(String[] args) {
    Timer timer = new HashedWheelTimer();
    Timeout timeout1 = timer.newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) {
            System.out.println("task1: " + new Date());
        }
    }, 3, TimeUnit.SECONDS);
    if (!timeout1.isExpired()) {
        timeout1.cancel();
    }
    timer.newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) {
            System.out.println("task2: " + new Date());
        }
    }, 2, TimeUnit.SECONDS);
    timer.newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws InterruptedException {
            System.out.println("task3: " + new Date());
            Thread.sleep(5000);
            int i = 1/0;
        }
    }, 1, TimeUnit.SECONDS);
    //直接终止 忽略待执行的任务
    //Set<Timeout> stop = timer.stop(); 会返回没有执行的任务
}

task3: Mon Jul 11 10:30:00 CST 2022
10:30:05.966 [pool-1-thread-1] WARN io.netty.util.HashedWheelTimer - An exception was thrown by TimerTask.
java.lang.ArithmeticException: / by zero
	at com.example.nettydemo.demo1.concurrent.HashedWheelTimerTest$3.run(HashedWheelTimerTest.java:34)
	at io.netty.util.HashedWheelTimer$HashedWheelTimeout.expire(HashedWheelTimer.java:588)
	at io.netty.util.HashedWheelTimer$HashedWheelBucket.expireTimeouts(HashedWheelTimer.java:662)
	at io.netty.util.HashedWheelTimer$Worker.run(HashedWheelTimer.java:385)
	at java.lang.Thread.run(Thread.java:745)
task2: Mon Jul 11 10:30:05 CST 2022

可以看到任务1被取消,任务3先执行且5s后任务2才执行,可见任务是串行执行的,且单个任务的异常不会影响其他任务的执行。

国际惯例 ,我们从构造函数入手

HashedWheelTimer构造方法

Timer timer = new HashedWheelTimer();

public HashedWheelTimer() {
    //传入一个默认的ThreadFactory   那么相应的 我们也可以自己传入自定义的
    this(Executors.defaultThreadFactory());
}

public HashedWheelTimer(ThreadFactory threadFactory) {
    this(threadFactory, 100, TimeUnit.MILLISECONDS);
}

public HashedWheelTimer(
        ThreadFactory threadFactory, long tickDuration, TimeUnit unit) {
    this(threadFactory, tickDuration, unit, 512);
}

//最终调用到这个构造方法 传入 tickDuration = 100,ticksPerWheel = 512,leakDetection = true
public HashedWheelTimer(
            ThreadFactory threadFactory,
            long tickDuration, TimeUnit unit, int ticksPerWheel, boolean leakDetection) {
    //初始化槽位  下方HashedWheelTimer#createWheel
    wheel = createWheel(ticksPerWheel);
    //基于2次幂 -1 作 & 运算,等同取模,效率更高
    mask = wheel.length - 1;
    //转纳秒
    this.tickDuration = unit.toNanos(tickDuration);
    //创建工作线程 线程中传入任务 worker(new Worker() 属性)
    workerThread = threadFactory.newThread(worker);
    leak = leakDetection || !workerThread.isDaemon() ? leakDetector.open(this) : null;
}

HashedWheelTimer#createWheel

 private static HashedWheelBucket[] createWheel(int ticksPerWheel) {
    ticksPerWheel = normalizeTicksPerWheel(ticksPerWheel);
    HashedWheelBucket[] wheel = new HashedWheelBucket[ticksPerWheel];
    for (int i = 0; i < wheel.length; i ++) {
        //填充HashedWheelBucket
        wheel[i] = new HashedWheelBucket();
    }
    return wheel;
}

取不小于 ticksPerWheel的最下2次幂
private static int normalizeTicksPerWheel(int ticksPerWheel) {
    int normalizedTicksPerWheel = 1;
    while (normalizedTicksPerWheel < ticksPerWheel) {
        normalizedTicksPerWheel <<= 1;
    }
    return normalizedTicksPerWheel;
}

也可以参考hashmap的做法
 static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

可以看到构造方法创建了一个HashedWheelBucket[]数组,这就是槽位了,默认512个槽位。 创建了一个线程,传入了一个任务worker,暂未启动,可以猜测的是当用户传入任务时线程才启动,然后worker死循环运转。

HashedWheelBucket

private static final class HashedWheelBucket {
    private HashedWheelTimeout head;
    private HashedWheelTimeout tail;

    public void addTimeout(HashedWheelTimeout timeout) {
        assert timeout.bucket == null;
        timeout.bucket = this;
        if (head == null) {
            head = tail = timeout;
        } else {
            tail.next = timeout;
            timeout.prev = tail;
            tail = timeout;
        }
    }
}

可以看到HashedWheelBucket就是一个HashedWheelTimeout组成的链表

时间轮加入任务 HashedWheelTimer#newTimeout

我们在看下加入定时任务时如何处理的 HashedWheelTimer#newTimeout

Timeout timeout1 = timer.newTimeout(new TimerTask() {
    @Override
    public void run(Timeout timeout) {
        System.out.println("task1: " + new Date());
    }
}, 3, TimeUnit.SECONDS);


public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
    //激活线程  首次调用可能短暂等待  下方HashedWheelTimer#start
    start();
    //获取deadline转化为纳秒
    long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;
    //将任务包装为HashedWheelTimeout
    HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
    //Queue<HashedWheelTimeout> timeouts = PlatformDependent.newMpscQueue(); MPSC队列
    timeouts.add(timeout);
    return timeout;
}

可以看到加入的任务包装成了HashedWheelTimeout,随后加入到一个MPSC队列中,结合下方的start方法,那么所有的处理逻辑应该都是在Workder之中了

HashedWheelTimer#start

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)) {
                workerThread.start();
            }
            break;
        ...
    }
    //startTime long类型 初始值为0
    while (startTime == 0) {
        try {
            // CountDownLatch startTimeInitialized = new CountDownLatch(1);
            // 等待计数器归0  那么相当于主线程会阻塞 ,肯定会有异步线程调用countDown,
            //毫无疑问就是上面的workerThread,而workerThread内部绑定的就是Worker,所以我们去看下Worker内部的逻辑
            startTimeInitialized.await();
        } catch (InterruptedException ignore) {
            // Ignore - it will be ready very soon.
        }
    }
}

查看Worker执行逻辑之前先简单梳理下 :

创建的HashedWheelTimer内部会维护一个HashedWheelBucket[],每个数组单元就是一个槽位,HashedWheelBucket内部是HashedWheelTimeout组成的链表结构。HashedWheelTimeout是TimerTask(我们提交任务传入的参数)的包装, 我们加入的任务直接存放到MPSC队列timeouts中,暂时与HashedWheelBucket[]没有关联。

时间轮内部循环逻辑 HashedWheelTimer.Worker#run

HashedWheelTimer.Worker#run

public void run() {
    startTime = System.nanoTime();
    //激活主线程 告诉我这边就绪了 可以推任务了
    startTimeInitialized.countDown();
    do {
        //阻塞到下一次tick
        final long deadline = waitForNextTick();
        if (deadline > 0) {
           //mask用于快速取模的掩码 等于HashedWheelBucket[]长度-1,对mask取模等同取余,效率更高
           //计算下一个槽位,tick初始为0,那么开始时idx就是0
            int idx = (int) (tick & mask);
            //处理取消的任务 这个逻辑有点类似jdk的selector轮询,
               //取消cancelKeys时只是往set里存入,每次执行select时检查
            //而这里的逻辑时检查cancelledTimeouts 这个队列中是不是有取消的任务。
               //每次执行前都会看下,防止执行了不必要的任务
            processCancelledTasks();
            //取出第0位的 HashedWheelBucket
            HashedWheelBucket bucket = wheel[idx];
            //将任务从MPSC队列中取出 塞入槽位的链表中
            transferTimeoutsToBuckets();
            //处理槽位中的任务
            bucket.expireTimeouts(deadline);
            tick++;
        }
    } 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();
}

HashedWheelTimer.Worker#waitForNextTick

private long waitForNextTick() {
     //默认传入的100 tick计数从0开始 ,看方法名waitForNextTick,相当于是每100毫秒,时间轮tick到下一个槽位
    long deadline = tickDuration * (tick + 1);
    for (;;) {
        final long currentTime = System.nanoTime() - startTime;
        //下面这段逻辑就是 睡眠直到 下次tick后1毫秒 返回当前时间
        //tickDuration默认为100ms,设置的越小,worder的精度越高,+1ms是为了不会唤醒的太过频繁。
        long sleepTimeMs = (deadline - currentTime + 999999) / 1000000;
        if (sleepTimeMs <= 0) {
            if (currentTime == Long.MIN_VALUE) {
                return -Long.MAX_VALUE;
            } else {
                return currentTime;
            }
        }
        try {
            Thread.sleep(sleepTimeMs);
        } catch (InterruptedException ignored) {
            if (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_SHUTDOWN) {
                return Long.MIN_VALUE;
            }
        }
    }
}

HashedWheelTimer.Worker#transferTimeoutsToBuckets

private void transferTimeoutsToBuckets() {
    //相当于每100毫秒从队列中取10000个任务,那么队列是否支持排序,是否需要重排序就不重要了,
    //所以这是使用时间轮的好处,不需要考虑任务的添加,删除对海量数据队列的影响。
    for (int i = 0; i < 100000; i++) {
        HashedWheelTimeout timeout = timeouts.poll();
        //没有任务可取
        if (timeout == null) {
            break;
        }
        //任务取消 使用状态位标记,同理不需要考虑对队列其他元素的的影响
        if (timeout.state() == HashedWheelTimeout.ST_CANCELLED) {
            continue;
        }
        //计算总计需要多少下tick这个任务才会被执行
        long calculated = timeout.deadline / tickDuration;
        //tick是当前tick数 初始时为0,每执行完一次 + 1
        //假设tick为3,代表正在执行第4次tick,calculated为515,则代表该任务需要等到时间轮的下一轮循环时
        //才需要被执行。注意看下面,任务还是被加入到槽位的链表中,所以这个remainingRounds是判断需不需要执行
        //的关键。
        timeout.remainingRounds = (calculated - tick) / wheel.length;
        //有可能已经过了执行时间 比如delay直接传一个负数
        final long ticks = Math.max(calculated, tick); 
        //计算槽位
        int stopIndex = (int) (ticks & mask);
        HashedWheelBucket bucket = wheel[stopIndex];
        //加入链表 同时将bucket引用传给HashedWheelTimeout
        bucket.addTimeout(timeout);
    }
}

HashedWheelTimer.HashedWheelBucket#expireTimeouts

public void expireTimeouts(long deadline) {
    HashedWheelTimeout timeout = head;
    while (timeout != null) {
        boolean remove = false;
        //remainingRounds <= 0代表在本次时间轮的循环内,否则就是之后的轮次
        if (timeout.remainingRounds <= 0) {
            if (timeout.deadline <= deadline) {
                //try catch执行任务 不影响其他任务
                timeout.expire();
            } else {
               ...
            }
            remove = true;
        } else if (timeout.isCancelled()) {
            remove = true;
        } else {
            //没有执行的 remainingRounds- 1,使得下次再进如这个槽位的链表循环时可以被执行
            timeout.remainingRounds --;
        }
        HashedWheelTimeout next = timeout.next;
        if (remove) {
           //执行过的/已取消的任务在此处从链表中移除
           //可以看到时间轮将大量数据的队列重组 变为了小范围的链表重组,性能自然就高了
            remove(timeout);
        }
        timeout = next;
    }
}

总结

可以看到HashedWheelTimeout性能优越性在于无需考虑大队列的排序、重组。将任务分配到各个槽位,并标注round属性,来保证任务能得到有效执行。但其相比jdk的ScheduledThreadPoolExecutor而言会占用更多的内存,每个槽位都会维持一个链表结构,类似hashmap。同时在长时间内没有任务时或者后续的任务到期时间很长,时间轮并不会阻塞,会持续空转,有一定资源的浪费。需要解决空转的问题,可以参照kafka的实现。

用一个 DelayQueue 保存时间轮中的每个 Bucket,并且根据 Bucket 的到期时间进行排序,最近的到期时间被放在
DelayQueue 的队头。使用一个线程来读取 DelayQueue 中的任务列表,如果时间没有到,那么 DelayQueue 会一直处于阻塞状态

附:JDK 原生并发队列

阻塞队列 :

阻塞队列在队列为空或者队列满时,都会发生阻塞。

  • ArrayBlockingQueue: 最基础且开发中最常用的阻塞队列,底层采用数组实现的有界队列,初始化需要指定队列的容量。内部使用了一个重入锁 ReentrantLock,并搭配 notEmpty、notFull 两个条件变量 Condition 来控制并发访问。从队列读取数据时,如果队列为空,那么会阻塞等待,直到队列有数据了才会被唤醒。如果队列已经满了,也同样会进入阻塞状态,直到队列有空闲才会被唤醒。

  • LinkedBlockingQueue: 内部采用的数据结构是链表,队列的长度可以是有界或者无界的,初始化不需要指定队列长度,默认是 Integer.MAX_VALUE。LinkedBlockingQueue 内部使用了 takeLock、putLock两个重入锁 ReentrantLock,以及 notEmpty、notFull 两个条件变量 Condition 来控制并发访问。采用读锁和写锁的好处是可以避免读写时相互竞争锁的现象,所以相比于 ArrayBlockingQueue,LinkedBlockingQueue 的性能要更好。

  • PriorityBlockingQueue: 采用最小堆实现的优先级队列,队列中的元素按照优先级进行排列,每次出队都是返回优先级最高的元素。PriorityBlockingQueue 内部是使用了一个 ReentrantLock 以及一个条件变量 Condition notEmpty 来控制并发访问,不需要 notFull 是因为 PriorityBlockingQueue 是无界队列,所以每次 put 都不会发生阻塞。PriorityBlockingQueue 底层的最小堆是采用数组实现的,当元素个数大于等于最大容量时会触发扩容,在扩容时会先释放锁,保证其他元素可以正常出队,然后使用 CAS 操作确保只有一个线程可以执行扩容逻辑。

  • DelayQueue 一种支持延迟获取元素的阻塞队列,常用于缓存、定时任务调度等场景。DelayQueue 内部是采用优先级队列 PriorityQueue 存储对象。DelayQueue 中的每个对象都必须实现 Delayed 接口,并重写 compareTo 和 getDelay 方法。向队列中存放元素的时候必须指定延迟时间,只有延迟时间已满的元素才能从队列中取出。

  • SynchronizedQueue 又称无缓冲队列。比较特别的是 SynchronizedQueue 内部不会存储元素。与 ArrayBlockingQueue、LinkedBlockingQueue 不同,SynchronizedQueue 直接使用 CAS 操作控制线程的安全访问。其中 put 和 take 操作都是阻塞的,每一个 put 操作都必须阻塞等待一个 take 操作,反之亦然。所以 SynchronizedQueue 可以理解为生产者和消费者配对的场景,双方必须互相等待,直至配对成功。在 JDK 的线程池 Executors.newCachedThreadPool 中就存在 SynchronousQueue 的运用,对于新提交的任务,如果有空闲线程,将重复利用空闲线程处理任务,否则将新建线程进行处理。

  • LinkedTransferQueue 一种特殊的无界阻塞队列,可以看作 LinkedBlockingQueues、SynchronousQueue(公平模式)、ConcurrentLinkedQueue 的合体。与 SynchronousQueue 不同的是,LinkedTransferQueue 内部可以存储实际的数据,当执行 put 操作时,如果有等待线程,那么直接将数据交给对方,否则放入队列中。与 LinkedBlockingQueues 相比,LinkedTransferQueue 使用 CAS 无锁操作进一步提升了性能。

非阻塞队列

非阻塞队列不需要通过加锁的方式对线程阻塞,并发性能更好

  • ConcurrentLinkedQueue 它是一个采用双向链表实现的无界并发非阻塞队列,它属于 LinkedQueue 的安全版本。ConcurrentLinkedQueue 内部采用 CAS 操作保证线程安全,这是非阻塞队列实现的基础,相比 ArrayBlockingQueue、LinkedBlockingQueue 具备较高的性能。

  • ConcurrentLinkedDeque 也是一种采用双向链表结构的无界并发非阻塞队列。与 ConcurrentLinkedQueue 不同的是,ConcurrentLinkedDeque 属于双端队列,它同时支持 FIFO 和 FILO 两种模式,可以从队列的头部插入和删除数据,也可以从队列尾部插入和删除数据,适用于多生产者和多消费者的场景。