记一次golang定时器引发的诡异错误

作为一只在9127工作制下摸鱼的程序猿,周六自然是愉快的加班了。一早上除了一位新同学在我们的“敏捷迭代”下错删了接口之外没什么大事。

临近中午,突然隔壁组大佬找到我,表示有个go语言服务偶现panic的问题需要求助。了解了一下,原来是他们组的一个妹子(小姐姐??)写的代码的问题。okok,既然大佬都来找我了,帮忙解决下顺便……再好不过了。

咳咳,进入正题,将问题场景的代码先放出来:

	var flag bool

	timer1 := time.NewTicker(time.Millisecond * 500)
	timer2 := time.NewTicker(time.Millisecond * 1000)
 
	if flag {
		timer1.Stop()
	} else {
		timer2.Stop()
	}

	for {
		select {
		case <-timer1.C:
			//todo do something
		case <-timer2.C:
			//todo do something else
		}
	}

其实要实现的功能很简单,两个定时器timer1和timer2,根据flag条件的不同,停止其中一个定时器,后续业务流程只有一个定时器生效。当然,这个功能很简单,有更好的写法,先不说代码的好坏,这段代码中隐藏的问题其实很容易忽略。

这段代码有一定机率引起select的两个分支都会进入的情况,与预期不符合,导致执行错误分支代码时,出现不可预期的问题。

先看下Ticker的结构和NewTicker方法的源码:

Ticker:

type Ticker struct {
	C <-chan Time // The channel on which the ticks are delivered.
	r runtimeTimer
}

NewTicker方法:

func NewTicker(d Duration) *Ticker {
	if d <= 0 {
		panic(errors.New("non-positive interval for NewTicker"))
	}
	// Give the channel a 1-element time buffer.
	// If the client falls behind while reading, we drop ticks
	// on the floor until the client catches up.
	c := make(chan Time, 1)
	t := &Ticker{
		C: c,
		r: runtimeTimer{
			when:   when(d),
			period: int64(d),
			f:      sendTime,
			arg:    c,
		},
	}
	startTimer(&t.r)
	return t
}

可以看到,Ticker其实是对runtimeTimer的一个封装,增加一个成员C,用作定时器超时触发的通道。而NewTicker就是对Ticker的创建过程,新建了通道C,并构造了runtimeTimer的结构,其中成员方法f就是runtimeTimer超时触发的方法,先不考虑runtimeTimer内部的实现,看一下sendTime方法:

func sendTime(c interface{}, seq uintptr) {
	// Non-blocking send of time on c.
	// Used in NewTimer, it cannot block anyway (buffer).
	// Used in NewTicker, dropping sends on the floor is
	// the desired behavior when the reader gets behind,
	// because the sends are periodic.
	select {
	case c.(chan Time) <- Now():
	default:
	}
}

不得不说,这里的设计还是很精妙的,c是一个缓冲空间为1的通道,使用缓冲通道的特性,做到了定时器外部业务不阻塞内部调度的特性。当定时器超时时,会将当前时间放入通道中,如果通道已经满了,不能放入就丢弃。总之,不会阻塞定时器的内部调度。而外部在使用时,只要从Ticker.C这个通道中,不断读取即可,能取到值时,说明发生过超时,执行相应业务即可。

另外问题代码中还调用了Stop方法,Stop方法内部实现主要是调用runtimeTimer的方法来停止,停止之后,定时器超时不会再触发上述的sendTimer方法,即不会再向通道c中放入数据使外部使用者读到。

简单明确了Ticker的实现,现在回到问题代码,看下究竟有什么问题。一开始在创建timer1和timer2是调用了NewTicker方法,根据上面的分析,调用了这两个方法之后,底层的定时器已经开始计时,接下来执行if条件判断后才停止了其中一个定时器。一般来讲,这种相邻几行代码之间,应该间隔时间很短,都在纳秒级别,即还没等到定时器触发,就停止了定时器。但是,go语言的协程调度的机制其实无法保证这种时间间隔。当发生方法调用时,当前协程是有可能出让出所占有的线程,让其它协程先跑的。以此来对外呈现出并发的效果,协程之间并不能完全保证并行。关于go的协程调度机制,后续可以再详细聊一聊。

这样一来,如果某些时候,正好这个执行这个方法的协程在这两行中间出让了线程,就极可能导致两行代码之间的时间间隔超过超时时间。一旦发生这种情况,虽然按逻辑停掉了一个定时器,但是在停掉之前已经触发了一次。这样Ticker.C这个通道里已经被放入了触发数据,继续往下执行select时,自然两个分支都会进入,从而引发了预期之外的错误。

这种问题现在再回头来看,可能会觉得理所当然不该这么用,但是如果不了解定时器的实现,贸然使用时,很难发现这个问题点。

记录下来,防止后面再踩坑。至于要怎么修改,其实看一眼要实现的目的很容易想到,就不在此赘述了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章