Golang Ticker Reset异常的坑
前言
延迟执行的场景我们通常会使用time.NewTimer(…)来实现,当一些场合可能需要使用timer.Reset(…)方法修改超时时间,这时使用要多注意, 使用不当会导致Reset失败,或是重复执行两次的情况。
复现
下面这段代码我们是希望:"fmt.Println(“timeout:”, time.Now())"只是执行一次,然后就是deadlock的panic:
package main
import (
"fmt"
"time"
)
func main() {
timer := time.NewTimer(1 * time.Second)
c := make(chan struct{}, 1)
c <- struct{}{}
for {
select {
case <-c: // 执行一次
fmt.Println("start:", time.Now())
time.Sleep(2 * time.Second)
timer.Reset(5 * time.Second) // 重置前前实际上时间已经到了
case <-timer.C:
fmt.Println("timeout:", time.Now()) // 只希望执行一次
}
}
}
但是,实际执行结果如下:
[Running] go run "/Users/hyman/go/src/bsc-flow-backend/internal/infra/redis/locker/main/main.go"
start: 2025-01-27 15:48:06.429385 +0800 CST m=+0.000182501
timeout: 2025-01-27 15:48:08.434472 +0800 CST m=+2.005297501
timeout: 2025-01-27 15:48:13.43967 +0800 CST m=+7.010568084
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [select]:
main.main()
/Users/hyman/go/src/bsc-flow-backend/internal/infra/redis/locker/main/main.go:13 +0x80
exit status 2
[Done] exited with code=1 in 7.469 seconds
"timeout"执行了两次,这并不符合我们的预期。
分析
查看Timer的源码我们可以发现,"C"其实是一个带缓冲为1的chan:
type Timer struct {
C <-chan Time
r runtimeTimer
}
// NewTimer creates a new Timer that will send
// the current time on its channel after at least duration d.
func NewTimer(d Duration) *Timer {
c := make(chan Time, 1)
t := &Timer{
C: c,
r: runtimeTimer{
when: when(d),
f: sendTime,
arg: c,
},
}
startTimer(&t.r)
return t
}
当执行时间到了,就往C发送当前的时间,发送使用select default的方式来防止阻塞泄露的问题:
// sendTime does a non-blocking send of the current time on c.
func sendTime(c any, seq uintptr) {
select {
case c.(chan Time) <- Now():
default:
}
}
而Reset方法并不会重新创建"C",只是执行restTimer方法重置触发时间:
func (t *Timer) Reset(d Duration) bool {
if t.r.f == nil {
panic("time: Reset called on uninitialized Timer")
}
w := when(d)
return resetTimer(&t.r, w)
}
一般情况下Reset后执行的符合预期,但是当Reset前触发时间已经到了,这个时候"C"实际上是已经有数据,所以如果读取timer.C时就会立即执行,再当Reset后的时间到达时,又也会再触发一次,所以就会出现上面的情况"timeout"执行两次。
方案
解决方案就是reset前C有值时,就先读取出来。官方文档有强调使用Reset方法需要在定时器已经Stop或是“C”里已经没数据的情况下:
// For a Timer created with NewTimer, Reset should be invoked only on
// stopped or expired timers with drained channels.
同时也给出了使用姿势:
// if !t.Stop() {
// <-t.C
// }
// t.Reset(d)
所以,代码就可以改为:
package main
import (
"fmt"
"time"
)
func main() {
timer := time.NewTimer(1 * time.Second)
c := make(chan struct{}, 1)
c <- struct{}{}
for {
select {
case <-c: // 执行一次
fmt.Println("start:", time.Now())
time.Sleep(2 * time.Second)
if !timer.Stop() { // 返回false表示C里有值
<-timer.C
}
timer.Reset(5 * time.Second) // 重置前前实际上时间已经到了
case <-timer.C:
fmt.Println("timeout:", time.Now()) // 只希望执行一次
}
}
}
执行结果:
[Running] go run "/Users/hyman/go/src/bsc-flow-backend/internal/infra/redis/locker/main/main.go"
start: 2025-01-27 16:42:36.051956 +0800 CST m=+0.000152459
timeout: 2025-01-27 16:42:43.062077 +0800 CST m=+7.010485959
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [select]:
main.main()
/Users/hyman/go/src/bsc-flow-backend/internal/infra/redis/locker/main/main.go:13 +0x80
exit status 2
[Done] exited with code=1 in 7.697 seconds
符合预期
结论
timer调用reset前需要先执行stop方法,如果stop返回的false时,就说明定时器已经被触发,需要执行<-timer.C先读出数据
原文地址:https://itart.cn/blogs/2025/practice/ticker-reset-exception.html