Go 1.28 的 Timer 复制检测:Stop() 返回 false 之后,定时器为什么还在跑
time.Timer 和 time.Ticker 的方法都是指针接收者:func (t *Timer) Stop() bool、func (t *Ticker) Reset(d Duration)。调用 t.Stop() 时,编译器自动取地址,所以哪怕 t 是一个按值复制出来的结构体字段,代码也能正常编译,Stop() 也照常返回一个 bool。这就是问题所在:很多人下意识把 Timer 当成一个可以随便赋值、塞进 map、按值传参的普通结构体。
变更出处:Go 1.28 dev 变更汇总,对应 CL f6ddbafcf0;接口参考 pkg.go.dev/time#Timer、Go 1.28 release notes。这是 Go 1.28 开发周期内的草案变更,最终行为以该版本实际输出为准。
Timer 里装的是运行时状态,不是值
Timer 内部除了暴露的 channel,还有一个 runtime 侧的 timer 结构,注册到当前 P 的 timer heap 上,堆里登记的条目指向的是原来那个对象的 timer 字段地址(Go 1.23 起 timer 重写为 per-P heap、timer.lock 取代全局锁,同步 timer channel 也可被 GC 回收)。
按值复制后,副本里的 timer 字段是另一块内存,但堆里那条记录仍然挂在原对象上。于是:
副本.Stop()去堆里摘自己那份,找不到对应条目,直接返回false,原 timer 继续跑;副本.Reset()把副本的 timer 重新加进堆,现在堆里有两份,各自到点向同一个 channel 发送;- 回调(或 channel 上的接收方)被触发两次,
<-t.C多出一次 tick; - 最麻烦的是「停不掉」:代码里写了
Stop(),返回值没人看,行为上却表现为定时器永不停止。
以前怎么排查
这类 bug 逃得过现有工具:go vet 的 copylocks 只认 sync.Locker/noCopy,Timer 不在名单里;race detector 看不到——复制本身不是数据竞争;pprof 的 goroutine 栈里也没有 timer,它不是一个 goroutine。旧实现用一个 init bool 标记是否初始化,复制之后副本的 init 是 true,看起来「已经初始化好了」,一切正常,更难察觉。
实际排查基本靠读代码:找 time.Timer 出现过的地方,看它是不是以值形式出现在 struct 字段、map value、函数返回值、函数参数里,尤其是 *time.NewTimer(d) 这种解引用写法,几乎必然是复制。
Go 1.28 改成自指针检测
Go 1.28 把内部的 init bool 换成了指向自身的指针:初始化时记下 &t,之后凡是调用 Stop/Reset 等方法,先比对这个自指针是不是仍等于当前对象的地址。按值复制出来的副本,自指针指向的是原对象,一比就露馅,于是会被检测到并报错。具体报什么、是 panic 还是别的形式,以该版本的实际输出为准。
需要强调的是:能检测到不等于自动修好。检测只是把静默的错误变成显式错误,代码仍然要改成 *time.Timer / *time.Ticker。
before / after
// before:Timer 按值存在 struct 里,构造时还解引用复制了一次
type job struct {
timer time.Timer
}
func newJob(d time.Duration) job {
return job{timer: *time.NewTimer(d)}
}
func (j job) stop() bool {
return j.timer.Stop() // 操作的是副本,堆里那条还在跑
}
// after:持有指针,构造时直接用 NewTimer 的返回值
type job struct {
timer *time.Timer
}
func newJob(d time.Duration) *job {
return &job{timer: time.NewTimer(d)}
}
func (j *job) stop() bool {
return j.timer.Stop()
}
Ticker 同理:不要 *time.NewTicker(d),不要把 time.Ticker 放进 map 的 value 或按值传参,统一用 time.NewTicker 的返回值并用 *time.Ticker 持有。