编程 复制一个 time.Timer 会让整个 Go 程序卡死,panic 只给一句 ts set in timer

2026-11-04 21:09:36

一行 *time.NewTimer(d) 让 Go 1.23 之后的程序卡死:栈里看不出根因的排障笔记

升级 Go 1.23 后测试永久挂住,SIGQUIT 拿到的栈只显示 goroutine running on other thread; stack unavailable,最后靠大量打点才定位到根因:结构体里写了 obj.timer = *time.NewTicker(...)。这是复制 time.Timer/time.Ticker 值的典型后果,Go 1.23 起从“能跑”变成“必炸”。

最小复现

Go 1.22 正常,1.23 起异常:

illegalTimerCopy := *time.NewTimer(10 * time.Second)

go func() {
illegalTimerCopy.Reset(10 * time.Second)
}()

select {}

Linux 上表现为整个 runtime 冻结、不再继续;macOS 上抛 fatal error: ts set in timer。

更短的版本,连 Reset 都不用:

illegalTimerCopy := *time.NewTimer(time.Second)
illegalTimerCopy.Stop() // 永久阻塞

Ticker 同样中招,ticker := *time.NewTicker(d) 后再 Reset,panic 栈类似:

fatal error: ts set in timer
runtime.(*timers).addHeap
runtime.(*timer).maybeAdd
runtime.(*timer).modify
runtime.(*timer).reset
time.resetTimer
time.(*Ticker).Reset

为什么 1.22 能跑,1.23 起必炸

Go 1.23 改了两处定时器实现:

  1. 不再被引用的 Timer/Ticker 会立即变成可回收,即使没调 Stop。旧版本里,未 Stop 的 Timer 要等触发后才回收,未 Stop 的 Ticker 永不回收。
  2. Timer/Ticker 关联的 channel 变成无缓冲、容量 0,保证 Reset/Stop 之后不会再有之前准备好的陈旧值被收发。可见副作用是 len/cap 现在返回 0 而不是 1——靠轮询 len 判断能否 receive 的代码要改成非阻塞 receive。

复制出去的是旧结构体里的 timer 状态,而运行时认为这份状态已经归属别处,于是新副本被丢弃/回收后,Stop/Reset 操作的是一个运行时不再维护的 timer。官方立场(Ian Lance Taylor)是不恢复 1.22 的行为:从 Go 1.4 起文档就写了 “A Timer must be created with NewTimer or AfterFunc”(golang/go#8776),复制 Timer 值属于未定义用法。

触发条件与 GODEBUG 的边界

新行为只在主程序所在模块的 go.mod 里 go 行是 1.23.0 及以上时启用。

GODEBUG=asynctimerchan=1 可以让通道行为退回异步,但它只改通道形态,不会让复制 Timer 变得安全——该炸的还是会炸。

用 SIGQUIT 抓现场

进程挂住时向目标进程发 SIGQUIT(kill -QUIT ,终端前台可直接 Ctrl+\)。前面提到的实际案例里,拿到的栈只有 goroutine running on other thread; stack unavailable,看不出 timer 内部状态,这也是这类问题难定位的原因。

vet 与运行期的检查进展

  • CL 676335:给 go/analysis/passes/copylock 增加对 Timer/Ticker 等类型的复制检查。adonovan 拿 15K 个包的语料跑过,os.File 命中 267 处,time.Timer 命中 21 处;抽看的 time.Timer 命中几乎都是真 bug。少数误报形式是 reflect.TypeOf(time.Timer{}) 与 cmpopts.IgnoreTypes(...),它们只看类型不看值。
  • 本地以 go vet 的实际输出为准,不要用上面的语料数字推断自己仓库的规模。
  • CL 676537:给 time.Timer 的“复制后使用”加运行期动态检查(prattmic 提交)。2026-01 时的计划是进 Go 1.27,是否在你手上的版本可用,以具体 release notes 为准(需自行验证)。

正确修法

Timer/Ticker 一律用指针:*time.Timer / *time.Ticker,结构体字段也存指针。不要用值接收者方法;不要放进会被值拷贝的结构体或 map/slice 元素里。

type worker struct {
timer *time.Timer // 字段用指针,不是 time.Timer
}

func newWorker(d time.Duration) *worker {
return &worker{timer: time.NewTimer(d)}
}

func (w *worker) reset(d time.Duration) {
// Reset 前按文档做 drain
if !w.timer.Stop() {
select {
case <-w.timer.C:
default:
}
}
w.timer.Reset(d)
}

Go 1.23 起无缓冲通道让 drain 逻辑简化,但语义仍要看 time 包文档。

判断结构体是否能值拷贝,看它是否含 time.Timer/time.Ticker 值字段、sync.Mutex 等;一旦有,就按不可拷贝处理。

复制全文 生成海报 Go 定时器 并发 排障

推荐文章

程序员茄子在线接单