time.timer在万级并发下会拖垮调度器,因其底层四叉堆存在stop后节点堆积、gc压力线性增长及跨p虚假唤醒问题;须用container/heap自建最小堆,每个*timer需带heapindex字段并严格同步更新,less须判fn!=nil,睡眠须用time.until(堆顶)事件驱动,且每次nexttime变更后立即heap.fix。

time.Timer 在万级并发、毫秒级精度、频繁创建/取消的网络服务中,不是“不够用”,而是会直接拖垮调度器——它底层是 per-P 四叉堆 + 单 goroutine 驱动,但 runtime 的 timer 系统仍存在 Stop 后过期节点堆积、GC 扫描压力线性增长、以及跨 P 调度导致的虚假唤醒等问题。真要压测到 5w+ 连接每秒触发数百个超时任务(比如长连接心跳、RPC 超时、流控令牌续期),就得自己管堆。
为什么 container/heap + []*Timer 不能直接 work
常见错误是照着文档写:heap.Init(h) 后往 []*Timer 里塞,再 heap.Push(h, t) —— 看似结构对了,但一 Stop 就漏触发、一 Reset 就 panic、多 goroutine 并发操作堆就 panic: “invalid heap”。根本原因在于:container/heap 不感知定时器生命周期,它只管 slice 下标顺序。而 *Timer 被 Stop() 后,其 nextTime 不变、fn 被置 nil,但它还在堆里占位,Less() 仍拿它比,堆序早乱了。
-
Less(i, j int)必须先检查t[i].fn != nil && t[j].fn != nil,否则已 Stop 的 timer 参与比较会破坏堆序 - 每个
*Timer必须带heapIndex int字段,并在Push/Fix/Remove时同步更新,不能靠 slice 重排自动维护 - 不能用
heap.Remove(h, i)删除已 Stop 的 timer——它会挪动元素并调用Down,但你没保证被删元素的heapIndex已失效,后续Fix会写错位置
如何让二叉堆真正响应时间推进
别用 time.Sleep(1 * time.Millisecond) 轮询堆顶——不准、耗 CPU、且无法处理系统时间回拨。正确做法是“事件驱动式睡眠”:每次从堆顶取最小 nextTime,算出 time.Until(nextTime) 去 sleep,醒来后批量弹出所有 DueAt 的任务。
- sleep 前必须加锁检查堆是否为空,避免唤醒时堆已被清空,导致
time.Until(nil)panic - 弹出时用
atomic.LoadPointer(&t.fn)判断是否非 nil,防止 Stop 和触发竞态(Stop 写 nil,触发读 fn) - 绝不直接在 sleep goroutine 中执行
t.fn(),必须 send 到 worker channel;否则一个慢回调卡住整个定时器调度循环 - 若检测到
now.Sub(heapTop.nextTime) > 100*time.Millisecond,大概率是 NTP 回拨,需遍历全堆,把所有nextTime.Before(now)的 timer 强制标记为过期并移出
Reset 单 Timer 复用时的三个临界点
高频变更最近到期时间时,timer.Reset(dur) 返回 false 是常态,不是异常。它意味着 timer 已触发或已被 Stop,此时你不能忽略返回值继续用旧 timer。
- Reset 返回 false → 必须先
timer.Stop()(确保无残留),再time.NewTimer(dur)替换 - 堆顶变更后,必须立即
timer.Reset(time.Until(newTop.nextTime)),不能缓存、不能延迟,否则错过触发窗口 - 如果新堆顶时间是 2s 后,但当前 timer 还剩 1.9s 才触发,Reset 会重置倒计时;但如果 timer 已进入触发路径(runtime 正在准备回调),Reset 可能失败,所以必须检查返回值
最易被忽略的是:*堆中每个 Timer 的 nextTime 字段必须是原子可读写的,且所有修改(包括周期任务的下次计算)都必须配对调用 heap.Fix(h, t.heapIndex)**。少一次 Fix,堆顶就可能不是真最早的任务;多一次 Fix 但没更新 heapIndex,就会写错 slice 位置——这种 bug 不报 panic,只悄悄漏触发,压测时才暴露。











