了解Go中的并发性错误

230 阅读12分钟

本文探讨了Go编程语言的一个基本主张,即围绕并发性的语言特性使其更难引入bug,并根据经验结果评估了这一主张。论文的作者研究了在开源项目中发现的实际bug以及它们的修复方法,发现事实上,这些新的语言特性并不一定会使引入并发性bug更加困难。

这篇论文让我特别兴奋,有几个原因--首先,计算机科学中很多关于设计模式的讨论实际上并不包括任何经验性的结果(即在这些情况下使用这种模式实际上可以防止错误),而这篇论文展示了如何进行这样的研究。此外,在这样做的时候,这篇论文进入了编程语言设计的人为因素,最终导致了bug的出现。我还认为论文中的很多语言对软件工程师是有用的。最后,通过阅读论文中的错误,我学到了很多关于并发性的知识。

在这篇博文中,我将介绍一些关于Go和并发性的背景,然后深入探讨论文的发现。

关于Go和并发性的一些背景

本文首先介绍了一些关于并发性和Go如何处理并发性的背景。

什么是goroutine?

Go编程语言中的核心并发结构是 "goroutine"。goroutine 是一个可以在 Go 中并发运行的函数。与线程不同的是,这些线程是由Go运行时自己管理的--每个goroutine都不会产生新的线程。因此,理论上你可以在一台机器上运行数以百万计的goroutine,而不会像传统线程那样受到限制。

Goroutines很容易创建,可以通过在函数或匿名函数前添加go 关键字来创建。


func foo(msg string) {
  fmt.Println(msg)
}

func main() {
  go foo("bar") // starts a go routine
  go foo("baz") // starts another go routine
}

协调的Goroutines

与线程类似,Goroutines也有不同的协调行为机制。 该行为的作者区分了他们所谓的 "共享内存 "访问和 "消息传递"。

共享内存访问

共享内存访问是很多传统的编程语言用来协调线程之间的行为,涉及到Mutexes等结构,它协调对共享资源的访问,以及Condition Variables,它允许线程等待,直到满足某些条件才能继续处理。

这里有一个例子。


func foo () {
  p.mux.Lock()
  // Note that the `defer` keyword in go executes the following code
  // once the surrounding function completes.
  defer p.mux.Unlock()
  // Do some stuff
}

foo 在这段代码中,p.mux.Lock() 之后的所有行为都被一个Mutex包裹着,因此每次只能由一个goroutine执行。

消息传递

消息传递是goroutine的一种协调机制,它涉及到一个特定的语言概念 "通道"。一般的想法是,通道是一种通信结构,goroutine可以写入和读出。这些通道有效的关键是,向通道发送信息和从通道接收数据都是阻塞操作。这里有一个例子。


func main () {
  c := make(chan int)

  go func() {
    sum := 0

    for i := 0; i < 10; i++ {
      sum += i
    }
    c <- sum // write `sum` to `c`
  }


  final_sum := <- c // read value from `c`
  fmt.Println(final_sum)
}

在这个例子中,父goroutine正在使用通道c ,与子goroutine进行协调。值得注意的是,子程序将阻塞,直到行c <- sum ,直到父程序从通道中读取值。

消息传递和共享内存是否可以互换?

论文中没有明确说明的东西,但我认为有一个有用的背景,那就是用共享内存技术(如Mutexes和Condition Variables)和通道完成同样的任务是可能的。

关于如何实现 "监控程序 "来保护使用通道的共享内存的一些例子,请看这个帖子。该帖子还讨论了这两种方法的一些优点和缺点。

本文的方法论

在介绍了一些背景之后,我们终于可以进入研究本身了。重申一下,这项研究的目标是更好地了解Go引入的新的并发原语是否真的有助于防止bug。本文还讨论了每个bug的解决方案,并在文末留下了建立更多自动化工具以防止和自动修复其中一些bug的可能性。

寻找错误

任何像这样的研究的第一个挑战是要有真正的bug来研究。值得庆幸的是,有一些非常大的开源Go项目,每个项目都有一个问题跟踪器。本文作者使用了Docker、Kubernets、etcd、CockroachDB、gRPC和BoltDB,并在这些项目中搜索似乎与并发性有关的bug。他们找到了与并发有关的错误,试图重现这些错误,并研究了这些错误的修复方法。

错误分类法

有了错误,下一个大的挑战是想出一些方法来对错误进行分类和确定原因。作者在两个轴上划分了bug。第一个是错误的行为。作者将bug分为两种可能的 "bug "行为,即bug是否是 "阻塞 "的。一个 "阻塞 "bug是指一个goroutine因为被锁或通道阻塞而无法前进。阻塞性bug的一个典型例子是死锁--两个goroutine各自等待对方释放一些资源来继续。另一种类型的bug是 "非阻塞 "bug。在这些bug中,所有的goroutines都能继续进行,但会产生一些其他的非理想行为(他们只是做了错误的事情)。

之所以做这样的划分是有意义的,是因为解决方案是非常不同的--我们将在后面讨论这个问题,但有可能让静态分析器检测到死锁,以及其他阻塞的情况。

作者用来划分bug的第二个轴是bug的原因。这一点我已经暗指过了,但两个原因是:1.共享内存的使用,以及2.消息传递。

对于每一个象限,作者都对bug的具体原因及其解决方案做了深入的探讨。

解决方案和 "提升"

该研究的另一个方面是研究bug的解决方案,以更好地了解如何更容易地检测和潜在地自动解决常见的bug。 对于每个被确定的解决方案,作者测量了一个特定解决方案的提升提升是一个衡量标准,即给定一个错误的根本原因,所提出的解决方案能够修复它的概率是多少?举个具体的例子,与Mutex有关的bug经常被 "Move "操作(指移动锁的操作)修复,从而导致该bug/解决方案组合的提升值很高。

这是一个值得研究的错误修复属性,因为正如作者所指出的,高提升值表明这些错误有可能被自动修复。

研究结果

正如我在上面提到的,对于作者的错误分类法中的每一个象限,作者都对他们发现的具体错误以及如何解决这些错误进行了深入研究。

虽然我在这里不谈这些细节,但如果你有兴趣更好地理解并发性,我强烈建议你阅读该论文的相关部分。

阻塞性错误

对于阻塞性错误,作者发现消息传递和使用Go中的新通道结构,实际上比使用共享内存更容易导致阻塞性错误。大多数由消息传递引起的错误都是由于误解了消息传递的工作原理。

作者还发现,由消息传递引起的阻塞性错误更难发现。

另一方面,由共享内存引起的阻塞性bug通常有与传统语言相同的原因和解决方法,而且这些修复方法比修复与通道有关的bug更容易被理解。

阻塞Bug的解决方案

阻塞性bug的光明面是,大多数阻塞性bug都有相对简单和容易描述的解决方案。换句话说,这个类别的错误的解决方案具有很高的提升价值。

非阻塞性错误

在非阻塞性bug的案例中,研究结果非常不同,作者发现由传统共享内存引起的bug比由消息传递引起的bug多。此外,许多由传统共享内存模式引起的bug实际上通过切换到使用消息传递来解决。

这里需要注意的是,许多与消息传递相关的bug比传统内存相关的bug更难发现和修复。另外,许多与传统内存有关的bug是由容易被滥用的库造成的。

非阻塞性bug的解决方案

在修复非阻塞性错误的章节中,作者指出,许多与传统内存有关的错误是由滥用共享变量或不经意地在goroutine之间共享数据造成的。对这些问题最常见的解决办法是使用Mutexes来保护对共享数据的访问,以及改用通道来避免使用共享数据。

在这一节中,也有一些解决方案的提升值很高,一些值得注意的是原因 "滥用通道",以及解决方案的修复 "修复通道",还有原因 "匿名函数",以及修复策略 "使数据私有化"。

作者还在这一节中指出,自动检测器,如数据竞赛检测器,不能有效地检测所有类型的非阻塞检测器。

该研究的潜在问题

作者在一开始就指出了对研究有效性的一些威胁,他们指出,他们只能使用他们能够在这些开源项目中重现的bug。这些bug并不多(研究中的数据量相当小),而且很多bug是不可复制的,因此被排除在研究之外。

一个没有被提及但我认为仍然存在的问题是,共享内存相关的bug和消息传递bug之间的比较不一定是公平的比较。例如,比较由共享内存引起的bug和由内存传递引起的bug并没有考虑到代码逻辑的附带复杂性。换句话说,在这项研究中,我们没有对使用共享内存编写的相同逻辑和使用消息传递编写的相同逻辑进行并排比较。有可能由消息传递引起的错误只是一段很难写的逻辑,而程序员如果使用共享内存,也会写出同样的错误。

当然,这是很难控制的,我认为对这些bug的各种根源的调查有助于消除这方面的一些不确定性。

经验之谈

这篇论文有一些非常有趣的启示。一个重要的启示是,Go的很多新功能都是为了帮助减少并发性错误的数量,但实际上并没有达到这个目的。作者指出,这些问题很多都是由于语言的使用者滥用了这些语言特性。我认为这对语言和库的设计者提出的建议是,在引入现有开发者不一定熟悉的新概念时,总会有风险。 从非阻塞部分可以看出,消息传递可以帮助防止某些类型的bug,但在阻塞部分可以看出,它并不只是开箱即用,开发者知道如何安全使用它是很重要的。

这一启示开启了另一个有趣的问题--那就是为 "专业人员 "设计和为 "初学者 "设计不一定看起来是一样的。在这种情况下,消息传递显然可以在某些情况下使并发性错误更难产生,但对于那些不太熟悉的人来说,实际上会造成更多的问题。

对我个人来说,另一个有趣的收获来自于论文的实际方法。 我是软件工程研究的新手,我发现研究和分类bug的工作非常有趣。我想知道对于公司来说,对他们代码库中的错误进行这样的分析是否有价值,以数据驱动的方式找出减少软件缺陷的方法。

此外,在阅读这篇论文的过程中,我学到了很多关于并发性的知识。这种对实际发生的错误进行分类并记录的一般方法,是学习软件工程中的概念的一个绝妙方法。

我的开放性问题

这篇论文给我留下了几个开放性问题。一个是我在上面提出的观点,即了解某段代码的偶然复杂性是否对比较不同原因的bug有影响。

我的另一个主要的开放性问题是,选择使用传统的共享内存或消息传递是否对自动化单元或集成测试有任何影响。

顺便说一句,如果有人对论文中研究的bug的更多细节感兴趣,可以在Github找到它们。

总结

我在阅读这篇论文时感到非常愉快。如果你对软件工程研究感兴趣,这似乎是一个不错的开始。如果你对并发性感兴趣,我认为这篇论文阐明了编写正确的并发性代码所面临的许多挑战。 最后,我认为围绕分析错误及其解决方案所使用的许多语言似乎在一般的软件工程工作中很有用。

请读一读吧!