优步爆Go语言容易发生的数据并发争夺问题


Uber已经采用Golang(简称Go)作为开发微服务的主要编程语言。我们的Go monorepo由大约5000万行代码组成(还在增长),包含大约2100个独特的Go服务(还在增长)。

Go使并发性成为一流的公民;在函数调用前加上go关键字,就可以异步运行调用。Go中的这些异步函数调用被称为goroutines。开发人员通过创建goroutines来隐藏延迟(例如,对其他服务的IO或RPC调用)。两个或多个goroutines可以通过消息传递(通道)或共享内存进行数据通信。共享内存正好是Go中最常用的数据通信方式。

goroutines被认为是 "轻量级的",由于它们很容易创建,Go程序员们大量使用goroutines。因此,我们注意到,用Go编写的程序,通常比用其他语言编写的程序暴露出明显的并发性。例如,通过扫描运行在我们数据中心的数十万个微服务实例,我们发现Go微服务暴露的并发性比Java微服务高8倍。
更高的并发性也意味着可能出现更多的并发性错误。数据并发争夺是一种并发错误,它发生在两个或更多的goroutine访问同一个数据,其中至少有一个是写的,而且它们之间没有排序。
数据并发争夺是阴险的bug,必须不惜一切代价加以避免。

我们开发了一个系统,使用动态数据争夺检测技术检测Uber的数据争夺。这个系统在6个月的时间里,在我们的Go代码库中检测到大约2000个数据争夺,其中我们的开发人员已经修复了大约1100个数据争夺。

在这篇博客中,我们将展示我们在Go程序中发现的各种数据争夺模式。这项研究是通过分析210名独特的开发人员在6个月内修复的1100多个数据争夺来进行的。总的来说,我们注意到,由于某些语言设计的选择,Go更容易引入数据争夺。语言特性和数据争夺之间存在着复杂的相互作用。

1. Go 在 goroutine 中通过引用透明地捕获自由变量的设计选择是数据竞争的秘诀
Go 中的嵌套函数(又名闭包)通过引用透明地捕获所有自由变量。程序员没有明确指定在闭包语法中捕获哪些自由变量。

这种使用方式不同于Java和C++。Java lambda仅按值捕获,并且他们有意识地采用这种设计选择来避免并发错误 [ 1 , 2 ]。C++ 要求开发人员明确指定按值或按引用捕获。

开发人员通常不知道闭包内使用的变量是自由变量并通过引用捕获,尤其是当闭包很大时。Go 开发人员通常使用闭包作为 goroutine。由于引用捕获和 goroutine 并发性,除非执行显式同步,否则 Go 程序最终可能会对自由变量进行无序访问。

2. 切片slice是令人困惑的类型,会产生微妙且难以诊断的数据竞争
切片是动态数组和引用类型。在内部,切片包含一个指向底层数组的指针、它的当前长度以及底层数组可以扩展的最大容量。为了便于讨论,我们将这些变量称为切片的元字段。切片上的一个常见操作是通过追加操作来增长它。当大小达到容量时,进行新的分配(例如,当前大小的两倍),并更新元字段。当一个切片被 goroutines 并发访问时,很自然地通过互斥锁来保护对它的访问。

3. 并发访问 Go 内置的、线程不安全的映射会导致频繁的数据竞争 
哈希表 ( map ) 是 Go 中的内置语言功能,不是线程安全的。如果多个 goroutine 同时访问同一个哈希表,其中至少有一个试图修改哈希表(插入或删除一个项目),就会发生数据竞争。

虽然导致数据竞争的哈希表并不是 Go 独有的,但以下原因使其更容易在 Go 中发生数据竞争:

  1. Go 开发人员比其他语言的开发人员更频繁地使用map,因为map是一种内置的语言结构。例如,在我们的 Java 存储库中,我们发现每个 MLoC 有 4,389 个映射结构,而 Go 相同,每个 MLoC 有 5,950 个,高出 1.34 倍。 
  2. 哈希表访问语法就像数组访问语法(与 Java 的 get/put API 不同),使其易于使用,因此意外地与随机访问数据结构混淆。在 Go 中,可以使用table[key]语法轻松查询不存在的 map 元素,该语法简单地返回默认值而不会产生任何错误。这种容错性让开发者在使用 Go map 时沾沾自喜。

4. Go 开发人员经常在传递值(或方法超过值)方面犯错,这可能导致非平凡的数据竞争
Go 中推荐使用按值传递语义,因为它简化了逃逸分析,并为变量提供了更好的在堆栈上分配的机会,从而减少了垃圾收集器的压力。 
与所有对象都是引用类型的 Java 不同,在 Go 中,对象可以是值类型(结构)或引用类型(接口)。没有语法差异,这会导致同步构造的错误使用,例如sync.Mutex和sync.RWMutex ,它们是 Go 中的值类型(结构)。

如果一个函数创建了一个互斥体结构并通过值传递给多个 goroutine 调用,那么这些 goroutines 的并发执行对不同的互斥对象进行操作,这些互斥对象不共享内部状态。这会破坏对受保护的共享内存区域的互斥访问。

5. 消息传递(channels)和共享内存的混合使用使代码变得复杂并且容易受到数据竞争的影响

6. Go在其群组同步结构sync.WaitGroup中提供了更多的回旋余地,但是Add/Done方法的不正确位置导致了数据竞争。

7. 为 Go 的表驱动测试套件习语并行运行测试通常会导致产品或测试代码中的数据竞争
测试是 Go 的内置功能。后缀为_test.go的文件中的任何前缀为Test的函数都可以通过 Go 构建系统作为测试运行。如果测试代码调用 API testing.T.Parallel() ,它将与其他此类测试同时运行。我们发现由于这样的并发测试执行,会发生一大类数据竞争。这些数据竞争的根本原因有时在测试代码中,有时在产品代码中。

Go 推荐一个表驱动的测试套件习语编写和运行测试套件。我们的开发人员在一个测试中广泛编写了数十或数百个子测试,我们的系统并行运行这些子测试。这个习惯用法成为测试套件问题的根源,开发人员要么假设串行测试执行,要么在大型复杂测试套件中忘记使用共享对象。当产品 API 编写时没有线程安全(可能是因为不需要它),但被并行调用时,也会出现问题,这违反了假设。

总之,基于观察到的(包括固定的)数据争夺,我们详细阐述了 Go 语言范式,使在 Go 程序中引入争夺变得容易。我们希望我们在 Go 中数据竞争的经验能够帮助 Go 开发人员更加关注编写并发代码的微妙之处。未来的编程语言设计者应该仔细权衡不同的语言特性和编码习惯与它们创建常见或神秘的并发错误的潜力。