🔍 登录 注册

Go 定时器陷阱 从 Tick 泄漏到时间轮设计

Z ZeroIsStart 2026-10-23 👁 0 ⏱ 6min

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 检查表能挡掉绝大多数定时器问题:

  1. NewTicker、NewTimer 是否有对应的 Stop(优先 defer);
  2. 循环里是否重复创建 timer;
  3. ctx 是否传到底层 IO,而不是只在 select 层"看起来超时";
  4. 无缓冲 channel 发送是否有 default 或有界缓冲;
  5. 周期任务的处理逻辑是否与 tick 触发解耦;
  6. 大量短生命周期 timeout 是否做过压测。

小结

Go 的定时器 bug 集中在三处:忘记 Stop 造成 runtime timer 泄漏、select 只做到"看起来超时"而没把 deadline 传到底层、tick 处理时长超过周期造成漂移与丢事件。对应修复是 defer Stop、ctx 全链路传播、触发与执行解耦。只有在百万级且取消频繁的场景才需要时间轮,并且要先有 pprof 证据。把这六条固化进评审,定时器类问题基本不会再成为线上事故来源。

读完了?给作者一点支持

本文采用 CC BY 4.0 协议转载 · © ZeroIsStart

评论(0)

登录 后参与评论。

  • 还没有评论,来抢沙发。

🔗 相关文章