1 引言

在高性能网络编程中,定时任务非常常见:连接心跳、请求超时、会话清理、重试退避、空闲连接回收,最终都会落到同一个问题上:如何在未来某个时间点触发一段逻辑。

如果任务数量不大,直接使用 JDK 的 TimerScheduledThreadPoolExecutor 通常已经足够。它们基于最早到期时间维护任务顺序,语义清晰,精度也比较稳定。但在 Netty 这类网络框架中,超时任务往往具有两个特点:数量大、生命周期短。例如一次 RPC 请求、一次连接读写、一次握手过程,都可能注册一个超时任务,并且任务很可能在真正到期前被取消。

这时,定时器的瓶颈不只在“到点执行”,更在高频的任务提交和取消。如果每个任务都要参与堆结构调整,任务规模上来后,调度器本身就可能成为热点。

时间轮(Time Wheel)解决的是这个问题:它牺牲一部分调度精度,用近似 O(1) 的槽位定位,降低海量短周期定时任务的插入和取消成本。Netty 的 HashedWheelTimer 就是这一思想的典型实现。

本文围绕三个问题展开:

  1. 时间轮如何组织定时任务?
  2. Netty 的 HashedWheelTimer 如何把任务放入时间轮并触发执行?
  3. 时间轮适合什么场景,又有哪些边界?

2 时间轮的核心模型

可以把时间轮理解成一个环形数组。数组中的每个位置是一个槽位(bucket),每个槽位代表一段固定时间,称为一个 tick。工作线程维护一个指针,按照固定的 tickDuration 向前推进。每次指针落到某个槽位,就检查这个槽位上的任务是否已经到期。

一个基础时间轮包含几个关键概念:

  • tickDuration:指针每次推进的时间间隔,也决定了调度精度。
  • ticksPerWheel:时间轮槽位数量。Netty 会将其规范化为 2 的幂,便于通过位运算取模。
  • bucket:某个 tick 对应的槽位,内部用双向链表保存落到该槽位的任务。
  • remainingRounds:任务还需要等待多少圈。它用于表达超出一整圈范围的长延迟任务。

假设时间轮有 8 个槽位,tickDuration = 1s,当前指针位于槽位 0。

如果注册一个 3 秒后执行的任务,它会被放入槽位 3。当指针每秒推进一次,第三次推进到槽位 3 时,这个任务就可以被触发。

如果注册一个 10 秒后执行的任务,10 秒已经超过一圈 8 秒。此时任务仍然会落到 10 % 8 = 2 号槽位,但还要记录 remainingRounds = 10 / 8 = 1。第一次经过槽位 2 时只把轮数减 1;下一圈再次经过槽位 2,并且 remainingRounds 已经归零时,才真正执行任务。

这就是时间轮与普通队列最大的区别:它不是按全局到期时间严格排序,而是先把任务映射到槽位,再在槽位内判断是否到期。多个任务落到同一个槽位时,通过链表串联即可。

3 Netty 时间轮的 API 模型

Netty 对外暴露的定时器 API 很简单,核心是三个接口:TimerTimerTaskTimeout

Timer timer = new HashedWheelTimer();

TimerTask task = new TimerTask() {
    @Override
    public void run(Timeout timeout) throws Exception {
        System.out.println("1s 后执行");
    }
};

Timeout timeout = timer.newTimeout(task, 1, TimeUnit.SECONDS);

if (!timeout.isExpired() && !timeout.isCancelled()) {
    boolean cancelled = timeout.cancel();
    System.out.println("任务取消结果: " + cancelled);
}

这段代码里有三层含义:

  • TimerTask 表示真正要执行的业务逻辑。
  • Timer 负责接收任务,并把任务调度到未来某个时间点。
  • Timeout 是一次调度返回的句柄,用于查询状态或取消任务。

从使用者视角看,newTimeout 只是提交一个延迟任务;从实现视角看,它要完成四件事:启动工作线程、计算相对 deadline、构造 HashedWheelTimeout、把任务放入待转移队列。

需要注意,HashedWheelTimer 适合一次性超时任务,不是通用的周期任务调度器。如果要做固定频率或固定延迟的周期调度,通常仍应优先考虑 ScheduledExecutorService

4 HashedWheelTimer 的内部结构

在 Netty 中,时间轮的主体可以拆成四类对象:

  • HashedWheelTimer:定时器入口,持有时间轮数组、工作线程、待处理队列和取消队列。
  • Worker:单个后台线程,负责推进 tick、转移新任务、处理取消任务、触发到期任务。
  • HashedWheelBucket:时间轮上的槽位,内部维护一个双向链表。
  • HashedWheelTimeout:一次定时任务的内部表示,也是链表节点,保存 deadline、状态、前后指针和剩余轮数。

Netty 没有在调用 newTimeout 的业务线程里直接修改时间轮槽位,而是先把任务放入 MPSC 队列。真正的槽位分配由 Worker 线程在下一次 tick 中完成。

这个设计有两个好处:

  1. 业务线程提交任务时路径很短,只需要构造任务并入队。
  2. 时间轮数组和 bucket 链表主要由单个 Worker 线程修改,减少锁竞争。

取消任务也是类似思路。调用 Timeout.cancel() 时不会立刻从 bucket 链表中摘除节点,而是先 CAS 修改状态,再加入 cancelledTimeouts 队列。Worker 后续统一清理这些取消任务。

因此,Netty 时间轮的主流程可以概括为:

业务线程 newTimeout
    -> 构造 HashedWheelTimeout
    -> 放入 timeouts 队列

Worker 每个 tick
    -> 等待下一个 tick 到来
    -> 清理 cancelledTimeouts
    -> 将 timeouts 转移到 bucket
    -> 扫描当前 bucket 并触发到期任务
    -> tick++

5 添加任务:先入队,后上轮

newTimeout 的关键逻辑如下:

public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) {
    Validate.notNull(task, "task");
    Validate.notNull(unit, "unit");

    long pendingTimeoutsCount = pendingTimeouts.incrementAndGet();
    if (maxPendingTimeouts > 0 && pendingTimeoutsCount > maxPendingTimeouts) {
        pendingTimeouts.decrementAndGet();
        throw new RejectedExecutionException("too many pending timeouts");
    }

    start();

    long deadline = System.nanoTime() + unit.toNanos(delay) - startTime;
    if (delay > 0 && deadline < 0) {
        deadline = Long.MAX_VALUE;
    }

    HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline);
    timeouts.add(timeout);
    return timeout;
}

这里有几个细节值得关注。

首先,start() 会确保 Worker 线程启动。HashedWheelTimer 并不是构造后立刻运转,而是在第一次提交任务时启动后台线程。

其次,deadline 使用的是相对时间:System.nanoTime() + delay - startTime。这样 Worker 后续只需要拿当前时间相对 startTime 的偏移量进行比较,不依赖墙钟时间,也避免系统时间回拨带来的影响。

最后,任务只是加入 timeouts 队列,并没有马上写入 wheel[slot]。槽位计算和链表插入被推迟到 Worker 线程中执行。

6 调度循环:一个 tick 做四件事

Worker 的主循环是理解 HashedWheelTimer 的关键。

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++;
        }
    } while (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_STARTED);
}

每一轮 tick 的顺序是固定的:

  1. waitForNextTick() 等待下一个刻度到来。
  2. processCancelledTasks() 清理已经取消的任务。
  3. transferTimeoutsToBuckets() 将新提交任务从队列转移到槽位。
  4. bucket.expireTimeouts(deadline) 扫描当前槽位,执行真正到期的任务。
  5. tick++,指针推进到下一个刻度。

idx = tick & mask 是一个优化点。由于时间轮长度会被规范化为 2 的幂,mask = wheel.length - 1,所以 tick & mask 等价于 tick % wheel.length,但成本更低。

6.1 等待下一个 tick

private long waitForNextTick() {
    long deadline = tickDuration * (tick + 1);

    for (;;) {
        final long currentTime = System.nanoTime() - startTime;
        long sleepTimeMs = (deadline - currentTime + 999999) / 1000000;

        if (sleepTimeMs <= 0) {
            return currentTime == Long.MIN_VALUE ? -Long.MAX_VALUE : currentTime;
        }

        try {
            Thread.sleep(sleepTimeMs);
        } catch (InterruptedException ignored) {
            if (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_SHUTDOWN) {
                return Long.MIN_VALUE;
            }
        }
    }
}

这里的 deadline 不是某个任务的到期时间,而是下一个 tick 的目标时间。Worker 通过睡眠把自身节奏对齐到 tickDuration。这也解释了时间轮的精度边界:任务不会在任意纳秒级时间点被触发,而是在某个 tick 扫描当前 bucket 时被触发。

6.2 将新任务转移到槽位

private void transferTimeoutsToBuckets() {
    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);
    }
}

这段代码完成了时间轮最核心的映射:

任务 deadline
    -> deadline / tickDuration 得到目标 tick
    -> (目标 tick - 当前 tick) / wheel.length 得到 remainingRounds
    -> 目标 tick & mask 得到 bucket 下标

remainingRounds 解决的是“延迟时间超过一圈”的问题。任务虽然被挂到了某个 bucket 上,但并不意味着指针下一次扫到该 bucket 就会执行。只有 remainingRounds <= 0deadline <= 当前 tick deadline 时,任务才真正到期。

循环最多处理 100000 个任务,是为了避免某个 tick 被无限制的新任务转移拖住。如果业务线程持续高速提交任务,Worker 仍然需要保留推进时间轮和执行到期任务的机会。

6.3 扫描当前 bucket

public void expireTimeouts(long deadline) {
    HashedWheelTimeout timeout = head;

    while (timeout != null) {
        HashedWheelTimeout next = timeout.next;
        if (timeout.remainingRounds <= 0) {
            if (timeout.deadline <= deadline) {
                timeout.expire();
            } else {
                throw new IllegalStateException("timeout.deadline > deadline");
            }
        } else if (!timeout.isCancelled()) {
            timeout.remainingRounds--;
        }
        timeout = next;
    }
}

这里不是简单地“扫到 bucket 就执行”。每个任务还要经过两层判断:

  1. 如果 remainingRounds > 0,说明还没有绕够圈数,只递减轮数。
  2. 如果 remainingRounds <= 0,再用 deadline 判断是否真正到期。

因此,时间轮的 bucket 只是粗粒度分组,最终是否执行仍然由任务自己的 deadline 决定。

7 取消任务:调用方只改状态,Worker 延迟清理

Timeout.cancel() 的逻辑很短:

public boolean cancel() {
    if (!STATE_UPDATER.compareAndSet(this, ST_INIT, ST_CANCELLED)) {
        return false;
    }
    timer.cancelledTimeouts.add(this);
    return true;
}

取消操作没有直接访问 bucket 链表。调用方只负责把状态从 ST_INIT 改成 ST_CANCELLED,然后把任务放入取消队列。真正的链表移除发生在 Worker 的 processCancelledTasks() 中:

private void processCancelledTasks() {
    for (;;) {
        HashedWheelTimeout timeout = cancelledTimeouts.poll();
        if (timeout == null) {
            break;
        }
        timeout.removeAfterCancellation();
    }
}

这样做的收益是调用 cancel() 的线程不会与 Worker 争抢 bucket 链表结构。代价是取消任务不会立刻从链表上消失,最多会延迟到后续 tick 被清理。对于网络超时、请求生命周期这类场景,这种延迟通常可以接受。

8 与 JDK 定时器的差异

TimerScheduledThreadPoolExecutor 更像是“按到期时间排序”的定时器。它们维护最早到期任务,适合对调度精度和通用语义要求较高的场景。

时间轮更像是“按时间片分桶”的定时器。任务先被映射到某个槽位,Worker 再按 tick 扫描槽位。它不追求全局严格排序,而是用固定时间片换取更低的插入和取消成本。

两者的差异可以概括为:

维度JDK 定时器时间轮
数据结构优先级队列 / 堆环形数组 + bucket 链表
插入成本随任务量增长,需要维护堆序近似 O(1),计算槽位后入队或挂链表
取消成本依赖具体实现和清理策略调用方近似 O(1),Worker 延迟清理
调度精度相对更细tickDuration 限制
适用场景通用定时调度、周期任务海量短周期、可容忍 tick 级误差的超时任务

这里需要避免一个误解:时间轮不是在任何场景下都比堆更快。如果任务数量不大,或者调度精度要求很高,时间轮的收益并不明显,反而会引入固定槽位、空转 tick、延迟清理等成本。

9 时间轮的边界与取舍

时间轮的优势来自“分桶”和“单线程推进”,边界也来自这里。

首先是精度问题。tickDuration 越大,Worker 扫描间隔越长,任务触发误差越明显。把 tickDuration 调小可以提高精度,但 Worker 唤醒更频繁,空转成本也会上升。

其次是槽位数量。ticksPerWheel 越大,单圈能覆盖的时间范围越长,任务冲突也可能更少,但初始化空间更大。ticksPerWheel 太小,则更多长延迟任务需要依赖 remainingRounds,同一个 bucket 里的链表也可能变长。

再次是长延迟任务。基础时间轮用 remainingRounds 可以表达超过一圈的延迟,但如果大量任务的延迟跨度非常大,单层时间轮并不一定合适。更复杂的实现可能会引入层级时间轮或溢出轮,但实现成本也会随之增加。

最后是任务执行时间。Worker 负责推进时间轮,不能被耗时任务长时间阻塞。Netty 的实现会通过 executor 执行到期任务,但使用者仍然要避免把时间轮当作业务线程池使用。定时器应该负责“到点触发”,而不是承担重型业务计算。

10 总结

时间轮的核心思想并不复杂:用环形数组表示时间片,把任务按到期 tick 映射到槽位,再通过 remainingRounds 处理超过一圈的延迟任务。它牺牲的是任意时间点的精确排序,换来的是海量短周期任务下更低的提交和取消成本。

Netty 的 HashedWheelTimer 在这个模型上做了几个工程化处理:业务线程只入队,Worker 单线程推进;新增任务和取消任务都延迟到 tick 中统一处理;bucket 使用双向链表,便于尾插和节点移除;槽位下标用位运算计算,降低调度路径开销。

因此,时间轮适合网络 IO 超时、心跳检测、请求生命周期管理等场景。它不适合高精度调度、长时间跨度且数量巨大的任务集合,也不应该替代通用任务调度器。理解这些取舍,才能正确判断什么时候该使用 HashedWheelTimer,什么时候应该回到 JDK 的调度工具。