单层时间轮更适合网络超时判定,因其O(1)插入删除、无goroutine泄漏风险;需对齐tickMs边界、用list避免扩容抖动、回调绑定context并显式Stop。

直接用 time.AfterFunc 或 time.Timer 注册上万级连接超时任务,不出 5 分钟就会触发 GC 飙升、goroutine 泄漏、调度器卡顿——每个任务独占一个 timer + goroutine,本质是 O(log n) 堆操作,扛不住高频短延迟场景。
为什么单层时间轮比 channel+Ticker 更适合网络超时判定
网络空闲超时(如 TCP keepalive、WebSocket 心跳)通常集中在 30s–5min,且每秒新增数百到数千个任务。单层时间轮靠固定 tickMs 驱动指针扫描,插入/删除都是 O(1),不依赖系统 timer 资源;而用 time.Ticker 配合 map 查找过期连接,每 tick 都要遍历全部待检连接,空轮询严重,CPU 利用率虚高。
- 典型错误:把
time.Now().UnixMilli()直接当槽索引,没对齐tickMs边界 → 任务提前触发或漏触发 - 正确做法:用
expires := (now/tickMs)*tickMs截断当前 tick 起始点,再算相对偏移 - 性能拐点:当
wheelSize > 10000且tickMs < 10ms,time.Ticker唤醒太密,建议压测后选tickMs=100ms、wheelSize=300(覆盖 30s)
回调函数必须绑定 context 并显式 Stop(),否则泄漏不可避免
网络任务回调常含 I/O 操作(如关闭 conn、写日志、发通知),若未加 context 控制或忘记 Stop(),timer 会持续持有 conn 引用,导致连接无法释放、内存持续增长。
- 错误写法:
time.AfterFunc(timeout, func() { conn.Close() })—— 无取消机制,超时前 conn 已断也无法中止 - 正确模式:在回调里检查
ctx.Err() != nil,并在任务注册前创建带 cancel 的子 ctx,超时触发时调用cancel() - 关键细节:自研时间轮的
AddTimer接口必须返回可Stop()的句柄,不能只返回 bool 或 void
槽位用 *list.List 而不是 []func(),避免 slice 扩容抖动
高频注册场景下,同一槽位可能瞬间涌入数百任务。用切片存储回调函数,每次 append 可能触发底层数组扩容并 memcpy,造成毛刺;而 container/list 是链表,插入/删除稳定 O(1),且支持在遍历时安全删除节点。
立即学习“go语言免费学习笔记(深入)”;
- 别手写
for i := len(slot)-1; i >= 0; i--倒序删 —— 并发访问时仍可能 panic - 应封装为
slot.Remove(e)+slot.PushBack(fn),配合sync.RWMutex保护整个 bucket - 注意:不要在回调里调用
tw.AddTimer—— 可能导致当前 slot 正在遍历又被写入,需用独立 goroutine 或缓冲 channel 中转
真正难的不是实现轮子,而是让每个槽的 currentTime 指针严格对齐系统单调时钟、让回调执行不阻塞 tick 推进、让 Stop() 调用能立即切断 timer 关联的 goroutine —— 这三点没处理好,时间轮就只是个更复杂的内存泄漏源。


















