time.Timer/Ticker 与定时任务有哪些常见坑?
一句话结论:Timer 一次性、Ticker 周期触发,两者都挂在 runtime 的时间堆上:忘记 Stop 会白白占着定时器;Ticker 必须 defer Stop;循环里别用 time.After(每次分配新 Timer);复用的 Timer 要正确 Reset;回调里做重活会丢 tick。Go 1.23 调整了 Timer/Ticker 的通道语义(改为同步无缓冲),新版代码可少走老版本的“drain channel”模板。
常见坑与对策
| 坑 | 现象 | 对策 |
|---|---|---|
| Ticker 不 Stop | runtime 定时器持续存在 | defer ticker.Stop() 是铁律 |
| Timer 用完不 Stop | 长定时器迟迟不释放 | 提前结束时 defer t.Stop() |
| 循环里 time.After | 每次迭代分配一个 Timer | 改用 Ticker 或复用一个 Timer |
| 复用 Timer 的 Reset | 老版本残留值可能“提前触发一次” | 旧版本配合 Stop+drain;1.23 后语义已简化 |
| 回调执行过久 | 触发被合并/跳过 | 回调只投递信号,重活另起 goroutine |
| 依赖毫秒级准点 | Ticker 只是尽力而为 | 高精度场景评估漂移,必要时自实现 |
另外:time.Sleep 不可被取消,想“随时可中断的等待”要用 select 加 timer 或 ctx.Done。
安全的复用写法
func waitWithTimer(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop() // 提前返回也要释放
select {
case <-t.C:
return true // 等到了
case <-ctx.Done():
return false // 被取消
}
}
func main() {
tk := time.NewTicker(time.Second)
defer tk.Stop() // 别忘了
for range tk.C {
// 如果这里很耗时,期间的 tick 会被合并/跳过
}
}
常见追问 / 记忆点
- 追问:定时任务里要做耗时操作怎么办?——回调内只投递信号,真正工作丢给 worker,避免阻塞下一次触发。
- 追问:time.After 与 time.NewTimer 什么区别?——After 是一次性简写且每次分配,适合低频;高频循环自建 Timer 复用。
- 记忆点:Ticker 必 Stop、循环别 After、复用要 Reset、回调别阻塞。