Bootstrap

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

;