跳至内容
定时器的坑:Timer、Ticker、time.After 与重置语义

定时器的坑:Timer、Ticker、time.After 与重置语义

2026年6月20日·
yanlong

时间相关代码很少在 happy path 出错。真正麻烦的是取消恰好和超时同时发生、Timer Reset 时旧值已经在路上、Ticker 的消费者处理不过来,以及测试不得不真的等三秒。

从 Go 1.23 开始,Timer channel 的实现发生了重要变化。理解新旧语义边界,能删掉不少历史上必要、今天反而容易写错的排空代码。

time.After 适合一次性等待

select {
case result := <-resultCh:
    return result, nil
case <-time.After(500 * time.Millisecond):
    return Result{}, ErrTimeout
}

对于只执行一次的 select,这很直观。需要在循环中反复重置超时时,不要每轮创建新的 time.After:它会不断创建 timer,而且你无法主动 Stop 旧 timer。

循环复用应使用 time.NewTimer

Go 1.23 改变了什么

go.mod 声明 go 1.23 或更高版本的程序,channel-based timer 有两个关键变化:

  1. 不再被引用且未 Stop 的 Timer/Ticker 可以被 GC 回收;
  2. Timer channel 变为同步、无缓冲 channel。StopReset 返回后,后续接收不会再拿到旧配置产生的过期值。

因此现代代码中可以直接:

timer := time.NewTimer(timeout)
defer timer.Stop()

for {
    timer.Reset(timeout)
    select {
    case <-timer.C:
        return ErrTimeout
    case event := <-events:
        handle(event)
    }
}

不过这里第一次进入循环前,timer 已经在运行,直接 Reset 没有表达清楚“旧等待是否仍有效”。更稳妥的写法是创建时使用真实首个 timeout,之后只在一次接收完成后 Reset;或者用一个辅助函数管理 Stop/Reset。

兼容旧语义时如何安全 Reset

Go 1.23 以前,Timer channel 容量为 1,Stop/Reset 后可能仍有旧值等待接收。兼容旧模块语义的经典写法是:

if !timer.Stop() {
    select {
    case <-timer.C:
    default:
    }
}
timer.Reset(timeout)

但这段代码只有在确定没有其他 goroutine 同时接收 timer.C 时才可推理。Timer 应由一个 goroutine 拥有和重置,不要把它当作多方共享的调度器。

Go 1.23 新语义是否启用由主模块 go.modgo 版本决定,也可以用 GODEBUG=asynctimerchan=0/1 强制切换。维护库代码或跨版本服务时,要明确自己的最低版本,而不是从网上复制某个年代的 Reset 模板。

不要用 len(timer.C) 判断是否到期

新 Timer channel 的 lencap 始终为 0。更重要的是,对任何 channel 使用 len 轮询都存在竞态:检查后状态可以立刻变化。

需要非阻塞检查时使用 select:

select {
case <-timer.C:
    // timer 已触发
default:
    // 当前没有可接收的值
}

这仍只是瞬时状态,不应拿来构建复杂时间状态机。

Context 超时和 Timer 怎么选

当超时需要沿调用链传播时,用 Context:

ctx, cancel := context.WithTimeout(parent, 500*time.Millisecond)
defer cancel()
result, err := client.Call(ctx, request)

当某个组件内部需要等待一个时间事件、周期调度或 debounce 时,用 Timer/Ticker。不要为了等待一次内部事件创建一棵没有传播价值的 Context,也不要用 Timer 替代应该传给数据库和 RPC 的 Context。

Ticker 不会为每次 tick 排队

ticker := time.NewTicker(time.Second)
defer ticker.Stop()

for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case now := <-ticker.C:
        refresh(now)
    }
}

如果接收方处理太慢,Ticker 会调整或丢弃 tick 以追赶时间。它不是“每秒必须执行一次且一次不能少”的任务队列。

如果任务耗时可能超过周期,要先决定语义:

  • 固定频率:允许跳过,避免并发重叠;
  • 任务完成后再等一段时间:用 Timer 在每次完成后 Reset;
  • 每个计划时刻都必须执行:需要持久化调度和补偿,不应依赖进程内 Ticker。

完成后再等待

timer := time.NewTimer(0)
defer timer.Stop()

for {
    select {
    case <-ctx.Done():
        return ctx.Err()
    case <-timer.C:
        if err := refresh(ctx); err != nil {
            log.Error("refresh failed", "error", err)
        }
        timer.Reset(interval)
    }
}

这样不会产生重叠执行,实际周期是“任务耗时 + interval”。

Stop 不会关闭 Channel

Timer.StopTicker.Stop 都不会关闭 C。如果关闭,正在 range 的调用方可能把“定时器停止”误解成一个普通 channel 生命周期事件;标准库选择不提供这种语义。

因此不要写:

for tick := range ticker.C {
    // 期待 Stop 后退出——不会发生
}

循环退出应同时监听 Context 或独立 done channel。

AfterFunc 没有可接收的 C

timer := time.AfterFunc(delay, func() {
    expire(key)
})

AfterFunc 在自己的 goroutine 中调用函数,返回的 Timer 的 C 为 nil。Reset 的语义也不同:如果 timer 仍活跃,它重新安排执行;如果已经触发或停止,它安排一次新的执行,但不能保证上一轮函数已经结束。

回调可能并发重叠时,函数本身需要同步。需要串行状态机时,通常让单一 goroutine 拥有普通 Timer 更容易推理。

定时器与 Select 的竞争是正常情况

取消和超时可能同时 ready:

select {
case <-ctx.Done():
    return ctx.Err()
case <-timer.C:
    return ErrIdleTimeout
}

select 不保证取消分支优先。两种结果在语义上都必须可接受;若外部协议需要统一,把返回前的最终状态判断集中到一个地方,而不是依赖 case 顺序。

让时间代码可测试

不要让领域逻辑到处直接调用 time.Now()time.After()。通常不需要引入庞大时间框架,一个很小的依赖就够:

type Clock interface {
    Now() time.Time
    After(time.Duration) <-chan time.Time
}

type RealClock struct{}

func (RealClock) Now() time.Time { return time.Now() }
func (RealClock) After(d time.Duration) <-chan time.Time {
    return time.After(d)
}

对于复杂调度,fake clock 需要正确实现 Timer Reset、Stop 和并发语义,自己写并不轻松。另一种办法是把“计算下一次截止时间”的纯函数与“等待”分开,大部分测试只验证纯函数。

版本与所有权是两条主线

排查 Timer 问题时,先确认主模块的 Go 版本,再确认哪个 goroutine 拥有 Stop、Reset 和接收。多数问题不是时间精度,而是多个参与者在修改同一个时间状态机。

延伸阅读