网上总是能看到有人说go自带了死锁检测,只要有死锁发生runtime就能检测到并及时报错退出,因此go不会被死锁问题困扰。
这说明了口口相传知识的有效性是日常值得怀疑的,同时也再一次证明了没有银弹这句话的含金量。
这个说法的杀伤力在于它虽然不对,但也不是全错,真真假假很容易让人失去判断力。
死锁检测失灵
死锁我就不多解释了,我们先来看个简单例子:
package mainimport ("fmt"
)func main() {c := make(chan int, 1)fmt.Println(<-c)
}
这段代码会触发golang的死锁报错:
fatal error: all goroutines are asleep - deadlock!goroutine 1 [chan receive]:
main.main()/tmp/deadlock.go:9 +0x32
exit status 2
这个例子为啥锁死了,因为没人给chan发数据,所以接收端永久阻塞在接收操作上了。
这说明了go确实有死锁检测。只不过你要是觉得它什么样的死锁都检测到那就大错特错了:
package mainimport ("fmt""time"
)func main() {c := make(chan int, 1)for {go func() {fmt.Println(<-c)}()time.Sleep(10 * time.Millisecond)}
}
根据示例1我们可以知道如果一个chan没有发送者,那么所有的接收者都会阻塞,在我们的例子里这些协程是永久阻塞的,理论上应该会被检测到然后报错。
遗憾的是这个程序会持续运行下去,直到内存耗尽为止:
死锁检测是有足够的时间执行的,因为10毫秒虽然对人类来说短的可以忽略但对golang运行时来说相当漫长,而且我们在不停创建协程,满足所有触发检测的条件,具体条件后面会细说。
从实验对照的角度来说,这时合理的猜测应该是会不会主协程被特殊处理了,因为上面的例子里子协程全部死锁,但主协程并没有。所以我们再次进行测试:
package mainimport ("fmt""time"
)func main() {c := make(chan int, 1)go func() {for {fmt.Println("Hello from child.")time.Sleep(100 * time.Millisecond)}}()<-c
}
这段代码同样不会报错,程序会持续输出Hello直到你手动终止进程或者关机为止。这正说明了runtime不会在死锁检测上特殊对待主协程。
看上去go的死锁检测时常“失灵”,这是一件很恐怖的事情,尤其是在你信了文章开头那个说法在代码里放飞自我认为只要没报错就是没问题之后。
go的死锁检测到底检测了什么
说这是“失灵”其实有失偏颇,上面的现象解释起来其实很简单,三两段话就能说明白。
首先我们可以把go里的协程分为两大类,一类是runtime自己的协程,包括sysmon和gc;另一类是用户创建的协程,包括用户自己创建的,用户使用的第三方库/标准库创建的所有协程。我们暂且管后者叫“用户协程”。这只是很粗糙的分类,实际的代码中有不少出入,不过作为抽象概率帮助理解是没问题的。死锁检测针对的就是“用户协程”。
知道了检测范围,我们还需要知道检测内容——换句话说,什么情况下能判断一组协程死锁了?理想中当然是检测到一组协程循环等待某些条件或者阻塞在一些永远不会有数据的chan上。现实是go只检测这些:
- 是否有协程处于运行状态,包括并未实际运行在等待调度的“可运行”用户协程;
- 没有上述条件的协程就检测是否还有未触发的定时器;
- 都不满足才会触发死锁报错并终止程序。
检测的时机其实也是有些反直觉的,go只在创建/退出操作系统级别的线程、这些线程变为空闲状态时、sysmon检测到程序处于空闲时才会执行死锁检测。也就是说,触发检测其实和操作系统线程相关性更强而不是和goroutine。
所以,只要还有一个协程能继续运行,哪怕其他99999个协程都锁地死死得,go的死锁检测依然不会报错(更正确的说法是只要还有一个能继续运行的系统级线程,那就不算死锁,这样才能解释为什么有还未触发的定时器以及在等待系统调用也不算死锁)。这样解释了为什么示例2和3都能运行,因为2中主协程能正常运行,3中子协程能正常运行,因此其他的协程锁死了也不会报错。
检测还有两个例外:
- cgo管不了,因此go程序调用的c/c++代码的线程里锁死了go这边也没有办法
- 把go代码编译成c库之后死锁检测会主动关闭,因为如果c/c++代码没调用库里的函数的话,那就只有runtime协程存在,这时候检测会发现根本没有用户协程,这种检测没有意义。所以在这种情况下哪怕go代码真的全部死锁了也不会检测到。
有人估计觉得这是bug或者设计失误要急着去提issue了,但这不是bug!这只是看待问题的方式不同。
go采用的做法,比较正式的描述是“在期望时间内程序的运行是否能取得进展”,这里的进展当然是指的是否在运行或者有定时器/io要处理。以此标准只要还有用户协程能动,那说明“整个程序”并没有死锁——go里判断要不要触发死锁报错是以整个程序作为基准的,而我们通常的判断基准是所有用户协程都能在“期望时间内获得进展”才是没有问题。后者的要求更严格。
而且满足后者的检测实现起来很复杂,预计也会花费非常多的计算资源,从维护和运行性能的角度来说想做也不是很现实。所以go选择了前者,前者虽然不能处理所有的问题,但仍然能在早期阶段防止出现一部分死锁问题。
然而把死锁检测当成万金油保险丝的人就要倒霉了:
- 现实的项目中出现一次性锁死整个程序的情况其实是比较少的,更多的时间是像例子中那样一部分协程锁死;
- 锁死的协程除了不能继续运行之外,还会造成协程泄漏,更要命的是协程持有的对象都不会释放,所以还伴随着内存泄漏;
- 在一些程序里系统的一部分锁死了可能在短时间内影响不到其他部分,在web应用中很常见,这会让问题发生难以察觉,往往当你意识到出问题时整个程序已经到万劫不复的状态了。
所以死锁检测只能偶尔帮你一次,并不能当成救命稻草用。
死锁检测的源代码在"src/runtime/proc.go"的checkdead
函数里,感兴趣的可以自行把玩。
怎么检测死锁
既然报错不能料理所有情况,我们还能借助哪些工具定位是否有死锁发生呢?
其实没啥好办法,下面每一种方案都需要经验以及结合实际代码才能判断出结果。
第一种是观察协程数量或者内存占用是否异常。比如你的程序正常需要1000个协程,那么2千个协程也许不是出问题了,但出现2万个协程那肯定是不对劲的。内存占用同理。
这些数据很好获取,不管是go自带的pprof还是trace,或者是第三方的性能监控,都能很轻松的探测到异常。难的是如何定位具体的问题。不过这节说的是如何发现死锁,所以出现上述异常后把可能存在死锁放进排查方向里也就够了。因为协程泄漏虽然不一定都是死锁造成的,但死锁最直接的表现就是协程泄漏。
方案1的缺点也很明显,如果死锁的协程数量固定,或者产生死锁协程的速度很慢,那么监控数据上很难发现问题。我们也不可能简单地用服务没响应了来判断是不是出了死锁,无响应的原因实在是太多了。
此外uber开发的用于检测协程泄漏的库goleak也可以帮上一些忙,不过缺点是一样的。
第二种是用go trace或者调试器dlv看运行时的协程栈。如果栈里出现很多lock类函数或者chan收发函数,那么存在死锁协程的概率是比较大的。最重要的是要看不同协程的调用栈里是否存在循环依赖或者交叉加锁的情况。
方案2的缺点也很明显,第一个是需要在程序运行时获取调用栈信息,这会影响程序的性能,协程越多影响越明显;第二是分析调用栈现在没啥好的自动化工具往往得程序员自己上阵,如果协程数目巨大的话分析会变得极度困难。
而且调用栈里lock函数多不代表一定有死锁,也可能只是锁竞争激烈而已。
最后一种方案是借助go trace工具,trace里有个叫block profile的,这个可以统计哪些函数被阻塞住了。如果看到里面有大量的lock、select相关函数、chan相关操作函数,那么死锁的可能性很大。
但和方案2一样,方案3依然不能100%确定存在问题,还是要结合实际代码做分析。而且trace只能分析某个时间段内的程序运行情况,如果你的程序死锁问题是偶发的,那么很可能抓几百次trace数据都不一定能抓到案发现场。
最后结论就是没有银弹。所以与其期待有个万能检测器不如写代码的时候就提前预防问题发生。
总结
我之所以写这篇文章是因为很多golang的布道师居然会以golang有死锁检测所以能避免死锁错误为卖点宣传go语言,信以为真的go用户也不少。稍加实验就能证伪的说法如今依然大行其道,令人感叹。
软件行业是很难出现银弹的,因此多动手检验才能少踩坑早下班。