Go 定时器陷阱 从 Tick 泄漏到时间轮设计
time.Timer 和 time.Ticker 看着简单,出问题时症状却都相似:内存缓涨、goroutine 泄漏、或者超时逻辑根本不触发。根因大多不是 API 用错,而是不了解运行时的实现约束。本文按三个典型 bug 展开,最后给高并发场景的时间轮思路。
一、Ticker 必须 Stop
time.NewTicker 返回底层 runtime timer。即使你不读 channel,runtime 仍会持续向缓冲为 1 的 channel 投递并维护它。忘记 Stop() 是 Go 里最经典的泄漏。
func badPolling(ctx context.Context) {
for {
t := time.NewTicker(5 * time.Second) // 每轮新建,旧的从不停止
select {
case <-t.C:
poll()
case <-ctx.Done():
return // 泄漏:t 仍在 runtime 中
}
}
}
func goodPolling(ctx context.Context) {
t := time.NewTicker(5 * time.Second)
defer t.Stop() // 一个 ticker + defer Stop
for {
select {
case <-t.C:
poll()
case <-ctx.Done():
return
}
}
}
观察手段是 pprof:inuse 栈里出现大量 runtime.addtimer、runtime.startTimer,就是 timer 泄漏。
二、Timeout 的两个隐性成本
每请求一个 timer 在高 QPS 下开销可观,稳妥做法是显式创建并 Stop:
func callWithTimeout(ctx context.Context, timeout time.Duration) error {
t := time.NewTimer(timeout)
defer t.Stop() // 命中返回后立刻释放
select {
case err := <-do(ctx):
return err
case <-t.C:
return ErrTimeout
}
}
另一个坑是 select 里复用 timer:Reset 之前若不保证 channel 已空,可能读到旧到期信号导致提前触发。
if !t.Stop() {
select {
case <-t.C:
default:
}
}
t.Reset(100 * time.Millisecond)
Go 1.23 起 timer channel 改为无缓冲且 Reset 会丢弃旧值,该坑已被修好;但需要跨版本兼容的代码仍建议显式排空。
三、超时不传播等于没有超时
这比泄漏更严重,是正确性问题。
// 错:ctx 无 deadline,DB 卡住时 goroutine 永久挂起,timer 只是"看起来超时"
func bad(ctx context.Context, q Query) error { return q.Exec(ctx) }
// 对:deadline 贯穿到底层 IO
func good(q Query) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
return q.ExecContext(ctx)
}
要检查的是每一层是否消费 ctx:HTTP client 传 ctx、DB 用 QueryContext、锁操作用带 ctx 版本。timer 只能取消 select,取消不了已经进入阻塞调用的 goroutine。
与之相连的还有无缓冲 channel 发送:没人接收时 goroutine 永久阻塞,context 也救不了。生产代码给内部 channel 一律带缓冲,并按最坏情况选择丢弃策略:
select {
case ch <- item:
default:
metrics.DropTotal.Inc() // 满了就丢并计数,绝不阻塞生产侧
}
四、精度与调度现实
Sleep 与 ticker 的精度是"至少这么久"而非"恰好"。三个限制来源:GC STW、P 调度延迟、以及处理耗时累积。
t := time.NewTicker(10 * time.Millisecond)
defer t.Stop()
for range t.C {
time.Sleep(25 * time.Millisecond) // 处理时间超过周期
}
上例中 ticker 会累积漂移并丢弃 Tick(channel 缓冲只有 1,处理慢时后续 tick 被丢),表现为周期任务越跑越慢。解法是把"触发"与"执行"解耦:
go func() {
for now := range t.C {
select {
case workCh <- now: // 进队列由 worker 池消费
default:
metrics.TickDropped.Inc()
}
}
}()
五、大量定时器场景的时间轮
Go runtime 用四层时间堆,插入删除摊还 O(1),几十万 timer 通常没问题。但在百万级、且大量 timer 在到期前被取消(长连接心跳、连接超时)时,堆的维护与标记成本会显现,这时时间轮(hashed timing wheel)才有价值。
type Wheel struct {
slots []timeSlice
tick time.Duration
current int
}
type timeSlice struct {
entries map[uint64]*Entry // id -> entry,支撑 O(1) 取消
}
func (w *Wheel) Add(d time.Duration, id uint64, fn func()) {
steps := int(d / w.tick)
idx := (w.current + steps) % len(w.slots)
w.slots[idx].entries[id] = &Entry{
id: id, fn: fn, round: steps / len(w.slots),
}
}
func (w *Wheel) advance() {
w.current = (w.current + 1) % len(w.slots)
s := &w.slots[w.current]
for id, e := range s.entries {
if e.round > 0 { e.round--; continue } // 未到本轮,留到下一圈
delete(s.entries, id)
workCh <- e.fn // 投给 worker 池,不原地执行
}
}
两条工程要点:到期回调必须投递到 worker 池而非原地执行,否则一个慢回调会拖住整个轮;是否需要时间轮要靠 pprof 证据判断(runtime.checktimers 占比明显),而不是凭感觉。多数服务用 sync.Pool + 显式取消 + 合理超时值就够了。
六、自检清单
放进 code review 检查表能挡掉绝大多数定时器问题:
NewTicker、NewTimer是否有对应的Stop(优先defer);- 循环里是否重复创建 timer;
- ctx 是否传到底层 IO,而不是只在 select 层"看起来超时";
- 无缓冲 channel 发送是否有 default 或有界缓冲;
- 周期任务的处理逻辑是否与 tick 触发解耦;
- 大量短生命周期 timeout 是否做过压测。
小结
Go 的定时器 bug 集中在三处:忘记 Stop 造成 runtime timer 泄漏、select 只做到"看起来超时"而没把 deadline 传到底层、tick 处理时长超过周期造成漂移与丢事件。对应修复是 defer Stop、ctx 全链路传播、触发与执行解耦。只有在百万级且取消频繁的场景才需要时间轮,并且要先有 pprof 证据。把这六条固化进评审,定时器类问题基本不会再成为线上事故来源。
评论(0)
登录 后参与评论。