作为一只在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时,自然两个分支都会进入,从而引发了预期之外的错误。
这种问题现在再回头来看,可能会觉得理所当然不该这么用,但是如果不了解定时器的实现,贸然使用时,很难发现这个问题点。
记录下来,防止后面再踩坑。至于要怎么修改,其实看一眼要实现的目的很容易想到,就不在此赘述了。