最近使用golang timer时写出了bug, 为了避免再出现类似问题以及对goalng timer实现细节的好奇,阅读了一遍timer的源码。golang timer使用的是时间堆算法。
一、 时间堆算法原理
时间堆算法的底层数据结构是基于数组的小顶堆,任意一个节点的值比其孩子节点值小,堆中最小元素总是位于根节点。用于定时器的堆一般为2叉堆或4叉堆。Golang Timer使用的是桶(Bucket)+4叉堆,每个桶包含1个4叉小顶堆。
- 节点访问
- tick创建
- 通过
NewTicker
或NewTimer
等函数创建timer - 根据当前G所在的P为timer分配一个桶(Bucket)
- 把当前timer加到桶中4叉堆的最后位置
- 向上调整timer在4叉堆的位置。(新timer加入后,可能会破坏小顶堆特性,所以需要调整。因为是加到的最后,所以只需要向上调整,直到当前堆满足小顶堆即可。)
- 通过
- tick删除
- 取出timer所在的桶(Bucket) 。
- 从桶(Bucket)的堆中删除timer,并调整堆。
- 被删除的timer可能在堆的任意位置,删除后会破坏小顶堆的特性,因此需要向上和向下调整。
- tick触发
当前桶首次创建timer时,会创建一个goroutine(timerproc)
,用于检查堆顶的tick是否可触发。当堆中没有timer时,timerproc
会一直睡眠,直到创建新timer时,调用了notewakeup
函数唤醒。
golang timer的原理很简单,但因其并发特性,在使用上带来了很多问题,如果对其原理和接口理解不清晰,姿势稍有不当,就会写出bug。比如timerproc
已经把timer从桶(Bucket)中删除但还没有调用sendTime
函数,其它goroutine调用Stop
或Rest
。在select中使直接或间接调用NewTimer
等。 在文章最后会介绍这些问题以及如何避免。
二、源码解读
源码版本(go version go1.12.7)
1 | //tick.go |
三、使用Timer时常见的错误
1.Rest、Stop问题
1.1 timer过期,channel值没排掉
如果t已经过期,但channel值没排掉(drained),那么t.Reset(d)
后,channel中的值依然存在。<-t.C
取出的值是上个timer过期值,显然不符合我们的期待。
1 | t.Reset(d) |
如果Reset前判断timer是否已经stop,并手动排掉,问题是不是就解决了呢? 值没排掉的问题解决了,但会有新问题。
1 | if !t.Stop() { //code1 |
如果多个goroutine中调用<-t.C
时,上面那段代码会有问题,可能会一直阻塞在<-t.C
。
如果加个判断len(t.C),channel中有值才执行<-t.C
,还会有问题吗?(一样会有多线程的问题,if语句和<-t.C并非原子操作)
1 | if !t.Stop() && len(t.C) > 0 {//code2 |
1.2 并发带来的问题
上面的例子还隐藏着一个巨大的问题。我们回顾一下timerproc
的源码
1 | f := t.f |
如代码所示,解锁后,才调用sendTime
往channel中写值,这点非常关键。它会导致一种很微妙的情况。如下:timerproc
中,某个timer到期,已经执行了从桶中删除timer,但还没往timer的channel写数据,此时另一个goroutine 执行了code2所在的整个代码块(t.Stop()返回false,这只能说明timer从桶中删除了,它可能没向c中发信号, len(t.C)
返回0,执行t.Reset(d)
)。这段代码执行后,在某个地方调用了 <-t.C
会立即取到值,但并不是我们所期待的值,而是上一个timer没有排掉的过期值。
上面这些情况如何避免呢?
- 用一个变量记录是否已经从channel中排掉值
- 同一个timer的
stop、reset、drained(排值)
操作在一个goroutine中执行
2.在select case
中直接或间接调用NewTimer
在下面例子的中case2永远不会执行,case1每次执行后,case1, case2都会重新new一个timer,而case1, case2会在新创建的timer channel上监听。如此往复
1 | func GO_func() { |
正确姿势:
1 | func GO_func() { |
3.没考虑channel被关闭的情况
1 | func GO_func() { |
当playerChannel
被关闭后,远永可以从playerChannel
中取出值(false), 所以会一直执行case2, 而且每次case1都会创新timer并在其channel上监听。
4.for select中break问题
在select中break不加标签,只能跳出select不能跳出for
1 | for { |
如果要跳出for,可以用break加标签,当然goto、return也可以。
1 | loop: |