论文链接:https://golangweekly.com/link/59972/b208593eda

最近在订阅的邮件中看到的一篇论文,来自宾州大学,第一次系统性地研究了几大 Golang 的开源软件中的由并发带来的 Bug。他们研究了以下几个软件的提交历史:Docker, Kubernetes,etcd,gRPC,CockroachDB 和 BoltDB,并得出了一系列很有趣的结论。

研究方法

这次研究的重点是并发相关的 bug,他们的研究方法是(扒了这些项目的黑历史):搜索了这些项目的 Github 提交历史,搜索“race“,“deadlock”,“synchronization”等关键字,或是和 Golang 特有的同步原语的关键字,如“context”,“once”,“WaitGroup”,等等,找出对同步 bug 的修复,甚至对某些 bug 进行了复盘和重现,并将这些 bug 归类为“阻塞”或是“非阻塞”。

不同项目中的bug数量及类型

阻塞 bug

阻塞 bug 是指部分或者所有 Goroutine 都阻塞导致部分或全局死锁的 bug。通常造成的问题的源头通常是互相等待。如下例:

(- 开头的是原始代码,+ 开头是修复 bug 的代码)

  // goroutine 1
  func goroutine1() {
      m.Lock()
-     ch <- request // blocks
+     select {
+         case ch <- request
+         default:
+     }
      m.Unlock()
  }
// goroutine 2
func goroutine2() {
    for {
        m.Lock()   // blocks
        m.Unlock()
        request <- ch
    }
}

在作者的研究中,发现消息传送(Message Passing)带来的 bug 比例甚至更高于使用传统 mutex 的比例!而且目前并没有非常成熟的检测手段。

Overall, we found that there are around 42% blocking bugs caused by errors in protecting shared memory, and 58% are caused by errors in message passing. Considering that shared memory primitives are used more frequently than message passing ones (Section 3.2), message passing operations are even more likely to cause blocking bugs.

个人观点:带来问题的原因可能有新的 Channel 同步原语并不被大家熟悉,也有可能是因为大家迷信 Channel 而放松了对 bug 的警惕。总而言之,Channel 强大,但并不是解决所有同步问题的万灵药。

非阻塞 bug

非阻塞 bug 指非阻塞,但是由于内存保护并不周到而导致的数据竞争问题,和 Go 特有的,因为 Goroutine Channel 没有及时发送或接受导致的 Goroutine 的泄漏问题等。

如下例,原来的代码采用 select-case,导致 default case 被意外重复执行多次,修复是完全用 Once 替代原来代码:

// when multiple goroutines execute the following code, default
// can execute multiple times, closing the channel more than once,
// which leads to panic in Go runtime

- select {
-     case <- c.closed:
          // do something
-     default:
+         Once.Do(func() {
              close(c.closed)
+         })
- }

另外,文章还针对不同的 bug 类型的修改方法作了研究,并对未来 Bug 检测工具的开发提出了建议。

结论和思考

  • Go Channel 带来的的并发模式功能强大,但并不是万灵药。在作者的研究中发现消息传送(Message Passing)带来的阻塞 bug 比例甚至更高!而且目前并没有非常成熟的检测手段。并发问题本身就很复杂,语言特性可能会降低复杂度,但不可能不通过严谨的分析轻松解决一切问题;
  • 在 Go 程序中,共享内存的同步(如最经典的互斥锁 lock/unlock)还是占更高比例。
  • 另外,滥用 Channel 可能还会带来性能问题(见这篇);
  • 作者的观察:很多 Go 的 bug 都有很类似的模式,利用这一点可以开发出更多的静态分析工具专门分析一个类型的问题。
  • 个人意见:在写 Go 代码的时候,无论使用同步锁还是 Channel,都尽量保证同步部分的代码短小精悍逻辑清晰,既减少 bug 发生,也易于验证和检查。
  • Go 编译器自带死锁和数据竞争检测,但并不能检测很多情况。更深入的 bug 研究可以考虑使用 gdb 等调试器,和 pprof 等运行时 profile 工具(见这一篇官方介绍);
  • HackerNews 上对这篇文章有更多讨论
  • Commit 信息不仅对回溯 bug 很有帮助,对未来系统性地分析历史 bug 也很有帮助。(在 Git 提交信息里写 asdfasdf 的小朋友们记住了。)