HashedWheelTimer的优势不是“定时更精确”,而是用固定刻度、环形桶和剩余轮数,把大量近似超时任务的调度成本摊薄。它适合连接超时、请求超时、重试和心跳检测,却不适合高精度调度,也不应该每个连接创建一个实例。
长连接系统里,真正让定时器变重的往往不是单个任务,而是任务数量:十万连接各有读空闲检测,几十万请求各有超时,失败重试又持续创建新任务。如果每次插入都维护一个严格有序的数据结构,任务增加会不断放大调度成本。
时间轮的取舍很明确:把时间切成离散刻度,允许到期时间存在一个 tick 量级的误差,换取接近常数时间的任务落桶和批量过期。理解这个取舍,才能知道它为什么适合网络超时,又为什么不能代替精密调度器。
源码基线:
• 项目:Netty • 版本: netty-4.1.135.Final• Commit: f05f765d81460799c53123a207f665bf3b465171• 重点文件: common/src/main/java/io/netty/util/HashedWheelTimer.java• 重点类与方法: HashedWheelTimer、newTimeout、Worker.run、transferTimeoutsToBuckets、HashedWheelBucket.expireTimeouts• 官方源码:https://github.com/netty/netty/blob/netty-4.1.135.Final/common/src/main/java/io/netty/util/HashedWheelTimer.java

这张图展示了时间轮的三个核心对象:提交线程只把 HashedWheelTimeout 放进 MPSC 队列,Worker 线程在每个 tick 把任务转移到对应 Bucket,指针走到某个槽位时再检查剩余轮数并执行到期任务。提交和执行没有争抢同一条有序链表,正是低成本的来源。
1. 时间轮优化的是“大量近似超时”,不是单次定时精度
HashedWheelTimer 可以看成钟表:wheel 是表盘,tickDuration 是每格时间,tick 是当前走过的格数。一个任务的截止时间会映射到某个槽位;如果截止时间超过一整轮,再用 remainingRounds 记录还要多转几圈。
假设:
• 每格 100ms; • 一共 512 格; • 一圈约 51.2 秒; • 某任务 125 秒后到期。
它会落到一个确定槽位,同时记录还需要经过若干完整轮次。Worker 每次经过该槽时先减少 remainingRounds,归零后才真正执行。任务不需要因为时间很远就占据一棵全局排序树的高层节点。
这也决定了精度边界:任务不会早于 deadline 执行,但可能因为 tick 粒度、Worker 调度延迟或任务本身阻塞而晚执行。若业务要求微秒级或严格准点,时间轮不是合适工具。
2. newTimeout 只入队,真正落桶由 Worker 串行完成
newTimeout 没有直接修改 wheel 中的双向链表。源码主路径可精简为:
public Timeout newTimeout(TimerTask task, long delay, TimeUnit unit) { long pendingTimeoutsCount = pendingTimeouts.incrementAndGet(); if (maxPendingTimeouts > 0 && pendingTimeoutsCount > maxPendingTimeouts) { pendingTimeouts.decrementAndGet(); throw new RejectedExecutionException("Number of pending timeouts ..."); } start(); long deadline = System.nanoTime() + unit.toNanos(delay) - startTime; HashedWheelTimeout timeout = new HashedWheelTimeout(this, task, deadline); timeouts.add(timeout); return timeout;}这段源码证明了两个设计点。
第一,调用线程负责构造任务并进入无锁队列,避免多个业务线程同时操作 Bucket 链表。第二,maxPendingTimeouts 是容量保护;如果不设上限,业务故障可能以“不断创建超时任务”的方式转化为堆内存压力。
取消任务也采用相似思路:调用方标记取消并进入 cancelledTimeouts 队列,Worker 再统一从 Bucket 移除。所有结构性修改集中在 Worker 线程,降低了锁和竞态复杂度。
3. deadline 通过掩码映射槽位,remainingRounds 表示完整圈数
时间轮长度会被规范成 2 的幂,因此槽位映射可以使用按位与:
long calculated = timeout.deadline / tickDuration;timeout.remainingRounds = (calculated - tick) / wheel.length;long ticks = Math.max(calculated, tick);int stopIndex = (int) (ticks & mask);wheel[stopIndex].addTimeout(timeout);mask = wheel.length - 1。当长度是 2 的幂时,ticks & mask 等价于取模,但计算更直接。Math.max(calculated, tick) 则处理已迟到的任务:即使 deadline 因排队或线程停顿已经过去,也会放到当前可处理的槽位,而不是落回历史刻度。
需要特别注意,O(1) 是“定位槽位”的复杂度,不代表到期处理永远 O(1)。如果大量任务集中在同一个槽位,Worker 到该 tick 仍要遍历整个 Bucket。业务把百万任务设为同一秒到期,会形成明显的到期尖峰。
4. Worker 每个 tick 只做四件事
Worker.run() 的主循环非常克制:
while (WORKER_STATE_UPDATER.get(HashedWheelTimer.this) == WORKER_STATE_STARTED) { final long deadline = waitForNextTick(); if (deadline > 0) { int idx = (int) (tick & mask); processCancelledTasks(); HashedWheelBucket bucket = wheel[idx]; transferTimeoutsToBuckets(); bucket.expireTimeouts(deadline); tick++; }}可以概括成:等待下一格、处理取消、搬运新增任务、过期当前桶。transferTimeoutsToBuckets 每轮还有搬运上限,避免提交洪峰让 Worker 一直搬任务而无法推进时间。
expireTimeouts 遍历当前桶:
• 已取消:移除; • remainingRounds > 0:减一,继续等待下一轮;• deadline 已到:从桶移除并执行; • deadline 未到:说明内部状态异常,源码会报错保护。
任务的 TimerTask.run(timeout) 就在 Worker 线程执行。因此一个慢任务会拖迟同一个 Timer 上的其他超时。这一点和 EventLoop 阻塞非常相似:定时器只负责触发,不是耗时业务线程池。
5. tickDuration 和 wheelSize 决定精度、跨度与扫描形态
参数不能只看“越小越准、越大越能装”。它们共同决定一圈时间:
一圈覆盖时间 = tickDuration × wheelSize参数影响如下:
网络超时通常允许几十毫秒到数百毫秒误差,远不需要每毫秒唤醒一次。应先确定业务可接受的超时误差,再估算典型延时范围和任务量,最后选择参数。
6. HashedWheelTimer 与 EventLoop.schedule 不是同一个调度器
Netty 里还有 EventLoop.schedule。它来自 AbstractScheduledEventExecutor,内部使用按 deadline 排序的优先队列,并由 EventLoop 自己执行。两者不能混为一谈。
ctx.executor().schedule | |||
HashedWheelTimer |
IdleStateHandler 默认使用前者,因为空闲状态和 Channel 事件需要在同一 EventLoop 传播。连接池、客户端请求超时管理器等框架组件可能使用共享时间轮,因为它们面对的是跨 Channel 的大规模超时集合。
不要为了“用了 Netty”就把所有定时任务都改成时间轮。选择标准是任务数量、精度要求和线程归属。

这张状态图强调了 newTimeout 之后任务并未立即进入 Bucket;Worker 搬运后,任务随 tick 多次经过目标槽位,remainingRounds 逐轮减少。取消和到期互斥,最终都要从 Bucket 删除并减少 pending 计数。
7. 一个 Timer 应被共享,而不是绑定每个连接
HashedWheelTimer 的每个实例都会创建一个 Worker 线程和一套 wheel。源码还会对创建过多实例给出资源泄漏警告。正确用法通常是在组件或进程级共享:
public final class TimeoutManager { private final HashedWheelTimer timer = new HashedWheelTimer(100, TimeUnit.MILLISECONDS, 512); public Timeout register(RequestContext request, long timeoutMs) { return timer.newTimeout(t -> request.tryTimeout(), timeoutMs, TimeUnit.MILLISECONDS); }}请求提前完成时必须取消对应 Timeout,否则任务仍会留到目标槽位才被清理。取消不是为了阻止已经完成的业务,而是为了尽快减少定时器中的无效对象和到期扫描成本。
同时,tryTimeout() 必须幂等。真实系统里正常响应、超时、连接关闭可能并发竞争,最终只能有一个路径完成请求状态。这和上一篇 Promise 的“一次完成”原则完全一致。
8. 定时任务堆积通常来自业务生命周期不闭环
线上看到 pending timeout 持续增长,不要第一反应就是时间轮泄漏。常见根因包括:
1. 请求成功后没有取消超时任务; 2. 重试每次创建新 Timeout,却没有取消前一轮; 3. 连接断开后批量任务仍引用 Channel 和业务上下文; 4. Worker 任务阻塞,过期速度低于创建速度; 5. 同一截止时间任务过多,单个 Bucket 形成尖峰; 6. maxPendingTimeouts没有限制,异常流量无限进入。
排查时至少记录:pending 数量、每秒创建/取消/过期数、任务执行耗时、单桶过期批量、超时延迟分布和拒绝次数。仅看“定时器线程 CPU”不足以定位生命周期问题。
9. 超时回调只做状态竞争,耗时动作交给别处
推荐把超时任务写成轻量状态转换:
timer.newTimeout(timeout -> { if (requestPromise.tryFailure(new TimeoutException())) { eventLoop.execute(() -> channel.writeAndFlush(timeoutResponse)); }}, timeoutMillis, TimeUnit.MILLISECONDS);这里有两条线程边界:时间轮 Worker 决定请求是否抢到超时完成权;与 Channel 相关的写和关闭动作回到 EventLoop。不要在 TimerTask 中直接做阻塞 RPC、数据库访问、大对象序列化或批量日志。
如果回调需要取消其他任务,也要避免形成长链递归。时间轮的优势建立在每个过期任务足够轻的前提上;一旦回调变成业务执行器,所有 deadline 都会被前一个慢任务拖延。
10. 停止 Timer 要处理未过期任务
stop() 不等于“等待所有任务执行”。停止后 Worker 会退出,并返回尚未处理的 Timeout 集合。组件关闭时应该明确决定:
• 未到期请求是统一失败,还是交给其他实例接管; • 是否还允许新任务提交; • 先停止业务入口,还是先停止 Timer; • 如何释放 Timeout 引用的 Channel、Buffer 和上下文。
正确顺序通常是先停止新流量,完成或失败在途请求,再停止时间轮。直接停 Timer 会让依赖超时回收的请求永久悬挂。
11. 时间轮的工程边界可以归纳为四条
时间轮主链如下:
newTimeout -> timeouts MPSC queue -> Worker transferTimeoutsToBuckets -> deadline / tickDuration -> stopIndex + remainingRounds -> waitForNextTick -> bucket.expireTimeouts -> TimerTask.run落地时记住四条:共享实例、限制 pending、回调轻量、状态幂等。时间轮解决的是大量超时任务的调度结构,不会自动解决请求生命周期、慢回调和关闭顺序。
下一篇进入操作系统边界:Epoll 与 KQueue 为什么不只是把 Selector 换了名字,它们如何通过 native Channel、事件数组和平台能力暴露 Linux/BSD 特性,以及什么时候值得为性能和功能承担平台绑定。
参考资料
1. Netty HashedWheelTimer.java:https://github.com/netty/netty/blob/netty-4.1.135.Final/common/src/main/java/io/netty/util/HashedWheelTimer.java2. Netty AbstractScheduledEventExecutor.java:https://github.com/netty/netty/blob/netty-4.1.135.Final/common/src/main/java/io/netty/util/concurrent/AbstractScheduledEventExecutor.java3. Netty Timer API:https://netty.io/4.1/api/io/netty/util/Timer.html
夜雨聆风