1 引言
在高性能网络编程中,定时任务非常常见:连接心跳、请求超时、会话清理、重试退避、空闲连接回收,最终都会落到同一个问题上:如何在未来某个时间点触发一段逻辑。
如果任务数量不大,直接使用 JDK 的 Timer 或 ScheduledThreadPoolExecutor 通常已经足够。它们基于最早到期时间维护任务顺序,语义清晰,精度也比较稳定。但在 Netty 这类网络框架中,超时任务往往具有两个特点:数量大、生命周期短。例如一次 RPC 请求、一次连接读写、一次握手过程,都可能注册一个超时任务,并且任务很可能在真正到期前被取消。
这时,定时器的瓶颈不只在“到点执行”,更在高频的任务提交和取消。如果每个任务都要参与堆结构调整,任务规模上来后,调度器本身就可能成为热点。
时间轮(Time Wheel)解决的是这个问题:它牺牲一部分调度精度,用近似 O(1) 的槽位定位,降低海量短周期定时任务的插入和取消成本。Netty 的 HashedWheelTimer 就是这一思想的典型实现。
本文围绕三个问题展开:
- 时间轮如何组织定时任务?
- Netty 的
HashedWheelTimer如何把任务放入时间轮并触发执行? - 时间轮适合什么场景,又有哪些边界?
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 很简单,核心是三个接口:Timer、TimerTask 和 Timeout。
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 中完成。
这个设计有两个好处:
- 业务线程提交任务时路径很短,只需要构造任务并入队。
- 时间轮数组和 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 的顺序是固定的:
waitForNextTick()等待下一个刻度到来。processCancelledTasks()清理已经取消的任务。transferTimeoutsToBuckets()将新提交任务从队列转移到槽位。bucket.expireTimeouts(deadline)扫描当前槽位,执行真正到期的任务。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 <= 0 且 deadline <= 当前 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 就执行”。每个任务还要经过两层判断:
- 如果
remainingRounds > 0,说明还没有绕够圈数,只递减轮数。 - 如果
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 定时器的差异
Timer 和 ScheduledThreadPoolExecutor 更像是“按到期时间排序”的定时器。它们维护最早到期任务,适合对调度精度和通用语义要求较高的场景。
时间轮更像是“按时间片分桶”的定时器。任务先被映射到某个槽位,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 的调度工具。