编程 Go 1.23 计时器语义变更:GC 与无缓冲 channel 引发的升级排障

2026-09-23 00:04:33

Go 1.23 计时器语义变更:GC 与无缓冲 channel 引发的升级排障

Go 1.23 换掉了 time.NewTimertime.Aftertime.NewTickertime.Tick 这些基于 channel 的计时器的实现,带来两处语义变化,升级时值得先确认一遍。

参考链接:

结论先行:两处语义变化

  1. 不再被引用的计时器可以被 GC 回收。 Go 1.23 之前,未 Stop 的 timer 必须等到触发才能回收,未 Stop 的 ticker 则永远不能回收。只要程序里没有调用 t.Stop,旧实现就会持续泄漏资源。新实现不再依赖 t.Stop 就能回收。
  2. timer channel 变成同步(无缓冲)的。 这给 t.Resett.Stop 提供了更强的保证:这两个方法返回之后,不会再从 timer channel 收到属于旧配置的过期时间值。旧实现下 t.Reset 无法彻底避免过期值,用 t.Stop 规避则要小心处理它的返回值;新实现把这层顾虑去掉了。

生效范围与 GODEBUG 开关

新实现只在 package main 所在模块的 go.mod 声明 go 1.23 或更高版本时启用。其他程序继续使用旧语义。

GODEBUG=asynctimerchan=1 强制旧语义,asynctimerchan=0 强制新语义。这两个值在排障时非常有用,后面会用到。

副作用一:timer channel 的 cap 和 len 恒为 0

Go 1.23 之前,timer channel 的 cap 是 1,len 表示是否有值等待接收(1 表示有,0 表示没有)。新实现创建的 timer channel,cap 和 len 始终是 0。

len 轮询任何 channel 本来就不是可靠做法:并发的另一个 goroutine 可能随时把值取走,len 的结果瞬间失效。轮询 timer channel 应该改用非阻塞 select

原来的写法:

if len(t.C) == 1 {
    <-t.C
    // more code
}

改成:

select {
default:
case <-t.C:
    // more code
}

副作用二:select 竞争——1ns 超时不再稳定走非超时分支

旧实现下,用 0ns1ns 这类极短间隔创建的 timer,从创建到 channel 可接收之间存在明显的调度延迟。下面这段代码正好能观察到这个延迟:

c := make(chan bool)
close(c)
select {
case <-c:
    println("done")
case <-time.After(1*time.Nanosecond):
    println("timeout")
}

select 的参数求值完成、真正去检查各 channel 时,timer 理论上早就到期了,两个 case 都处于就绪状态。select 在多个就绪 case 之间随机选一个,所以这段程序应该约一半概率走 done、一半走 timeout。但因为旧实现里的调度延迟,它实际上 100% 走 done

Go 1.23 的实现没有这个调度延迟,所以在新版本下,两个分支各约一半命中。

在 Google 代码库测试 Go 1.23 时,发现少量测试用 select 让已经就绪的 channel(通常是 context 的 Done channel)和超时极短的 timer 竞争。生产代码通常用真实超时值,这种竞争无所谓;但测试把超时设得极小,又要求必须走非超时分支,一旦超时就把测试打挂。简化后的例子:

select {
case <-ctx.Done():
    return nil
case <-time.After(timeout):
    return errors.New("timeout")
}

测试用 timeout = 1ns 调用这段代码,只要返回 error 就失败。

修法有两种:一是让调用方接受超时可能发生;二是让代码在超时分支里再确认一次 Done 是否就绪,优先走 Done:

select {
case <-ctx.Done():
    return nil
case <-time.After(timeout):
    // Double-check that Done is not ready, in case of short timeout during test.
    select {
    default:
    case <-ctx.Done():
        return nil
    }
    return errors.New("timeout")
}

排障:GODEBUG 对照 + bisect 定位栈帧

如果某个程序或测试在 Go 1.23 下失败、在 Go 1.22 下正常,可以先用 asynctimerchan 确认问题是否由新计时器触发:

GODEBUG=asynctimerchan=0 mytest  # force Go 1.23 timers
GODEBUG=asynctimerchan=1 mytest  # force Go 1.22 timers

如果强制用 Go 1.22 计时器时稳定通过、强制用 Go 1.23 计时器时稳定失败,基本可以确定问题出在计时器上。

目前观察到的失败案例,问题都在测试自身,而不是计时器实现。下一步就是找出 mytest 里究竟哪段代码依赖旧实现,可以用 bisect:

go install golang.org/x/tools/cmd/bisect@latest
bisect -godebug asynctimerchan=1 mytest

这样调用时,bisect 会反复运行 mytest,根据通向计时器调用的栈帧,动态开启或关闭新计时器实现。它用二分查找把引发失败的点收敛到某条具体的栈帧路径上,然后报告出来。

一次实际的 bisect 运行:

$ bisect -godebug asynctimerchan=1 ./view.test
bisect: checking target with all changes disabled
bisect: run: GODEBUG=asynctimerchan=1#n ./view.test... FAIL (7 matches)
bisect: target fails with no changes, succeeds with all changes
bisect: FOUND failing change set
--- change set #1 (disabling changes causes failure)
internal/godebug.(*Setting).Value() go/src/internal/godebug/godebug.go:165
time.syncTimer() go/src/time/sleep.go:25
time.NewTimer() go/src/time/sleep.go:144
time.After() go/src/time/sleep.go:202
region_dash/regionlist.(*Cache).Top() region_dash/regionlist/regionlist.go:89

输出里的栈帧直接指到了 regionlist.(*Cache).Top 里的那次 time.After 调用——就是用新计时器时引发失败的位置。

升级清单

  • 确认 go.mod 里声明的 go 版本。只有 go 1.23+ 的 main 包才会切到新实现,其他模块行为不变。
  • 全局搜索 len(t.C)cap(t.C) 这类对 timer/ticker channel 的轮询写法,改成非阻塞 select
  • 检查测试里是否存在「极短超时 + 与已就绪 channel 竞争」的 select。确认代码在超时分支下是否需要二次检查 Done channel。
  • 失败时先用 GODEBUG=asynctimerchan=0/1 对照跑一遍,确认是否与计时器相关。
  • 确认与计时器相关后,用 bisect -godebug asynctimerchan=1 定位到具体调用点。
  • t.Stop / t.Reset 的语义更强了:返回后不会再读到旧配置的过期值,原先为规避过期值写的防御逻辑可以重新审视。

推荐文章

程序员茄子在线接单