Go 1.23 计时器语义变更:GC 与无缓冲 channel 引发的升级排障
Go 1.23 换掉了 time.NewTimer、time.After、time.NewTicker、time.Tick 这些基于 channel 的计时器的实现,带来两处语义变化,升级时值得先确认一遍。
参考链接:
结论先行:两处语义变化
- 不再被引用的计时器可以被 GC 回收。 Go 1.23 之前,未 Stop 的 timer 必须等到触发才能回收,未 Stop 的 ticker 则永远不能回收。只要程序里没有调用
t.Stop,旧实现就会持续泄漏资源。新实现不再依赖t.Stop就能回收。 - timer channel 变成同步(无缓冲)的。 这给
t.Reset和t.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 超时不再稳定走非超时分支
旧实现下,用 0ns 或 1ns 这类极短间隔创建的 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的语义更强了:返回后不会再读到旧配置的过期值,原先为规避过期值写的防御逻辑可以重新审视。