Go 数据竞争模式
Aug 7, 2022 21:30 · 4030 words · 9 minute read
原文 https://eng.uber.com/data-race-patterns-in-go/
并发在 Go 中是一等公民;通过 go
关键字异步执行函数调用,被称作 goroutines。多个 goroutine 之间通过消息传递(channel)或共享内存通信,而共享内存是最普遍的数据通信方式。
goroutine 被认为很轻量因为容易创建,使用自由。因此用 Go 程序通常比其他语言写的并发度更高,而高并发度也意味着更多 bug。数据竞争是一种在多个 goroutine 访问相同数据时发生的并发 bug,其中至少有一个是写数据,并且它们没有顺序。数据竞争是潜在的,必须不惜一切代价避免。
本文将展示在 Go 程序中发现的多种数据竞争模式。这项研究是通过分析 210 位开发者在六个月内修复的 1100 多个数据竞争进行的,总之由于语言的设计选择,Go 更容易引入数据竞争。语言特性和数据竞争之间存在复杂的相互作用。
1. Go 的设计在 goroutine 中通过引用透明地捕获自由变量,隐含了数据竞争
闭包,在 Go 中通过引用透明地捕获所有自由变量。程序员没有明确地在闭包语法中指定哪个自由变量被捕获。
这种用法与 Java 还有 C++ 不同:Java lambda 只通过值捕获并且有意识地采用该设计避免数据竞争 bug;而 C++ 需要开发者明确地指定通过值还是引用捕获。
开发者通常不知道闭包内的变量是一个自由变量并通过引用捕获,尤其闭包很大时。Go 开发者更多地将闭包作为 goroutine 使用,由于通过引用捕获和 goroutine 并发,除非显示同步,Go 程序最终可能会出现对自由变量的无序访问。我们用三个示例来证明:
-
因为循环变量捕获导致数据竞争
1 2 3 4 5
for _, job := range jobs { go func() { ProcessJob(job) }() }
这里开发者在一个匿名 goroutine 中包裹
ProcessJob
函数。但是循环的变量job
在 goroutine 中通过引用捕获。第一次循环迭代启动的 goroutine 正在访问job
变量时,父 goroutine 中的 for 循环也在继续推进并更新相同的循环变量job
指向jobs
切片中的第二个元素,导致数据竞争。这种类型的数据竞争发生在值和引用类型上;切片、数组和 map;以及循环体中读写访问。Go 推荐在循环体中隐藏和私有化循环变量,不幸的是开发者并不总是遵循。 -
因为习惯性的
err
变量捕获导致数据竞争1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
x, err := Foo() if err != nil { // } go func() { var y int y, err = Bar() if err != nil { // } }() var z int z, err = Baz() if err != nil { // }
Go 提倡函数多值返回,同时返回真实返回值和错误对象来只是是否存在错误司空见惯。通常的做法是将返回的错误对象分配给一个名为
err
的变量,然后检查其是否为空。当开发者在 goroutine 中混用时,err
变量在闭包中通过引用被捕获。因此在 goroutine 中访问err
和随后对相同err
变量的读写会并发进行导致数据竞争。 -
因为具名返回变量捕获导致数据竞争
1 2 3 4 5 6 7 8 9 10 11 12 13 14
func NamedReturnCallee() (result int) { result = 10 if ... { return // this has the effect of "return 10" } go func() { ... = result // read result }() return 20 // this is equivalent to retult=20 } func Caller() { ret := NamedReturnCallee() }
Go 引入一种语法糖叫做具名返回值。具名返回变量被当做在函数之上的变量,作用域比函数体更广。没有参数的返回语句,即“裸”返回,返回具名返回值。在闭包的情况下,混用具名返回值或在函数中用具名返回值延迟返回是有风险的。
NamedReturnCallee
函数返回一个整数,返回变量被命名为result
。这种语法使得在剩余的函数体中无需声明就可以读写result
。如果函数在第 4 行返回,即裸返回,因为第 2 行的赋值语句result=10
,13 行的调用者能看到的返回值是 10。编译器将result
赋值到ret
。具名返回函数也可以用第 9 行这种标准返回语法,使得编译器将 20 复制到具名返回变量result
。第 6 行处创建了一个 goroutine,捕获了具名返回变量result
。在设置这个 goroutine 时大家都以为在第 7 行读取result
时安全的因为没有写入相同变量的情况;第 9 行的return 20
语句毕竟是一个常量返回,看上去没碰具名返回变量result
。但实际上,return 20
语句生成了对result
一次写入的代码。现在具备了对共享的result
变量的并发读写,存在数据竞争。
2. 切片可能会导致难以诊断的数据竞争
切片是动态的数组,是引用类型。切片内部包含了一个指向底层数组的指针、当前长度、和底层数组能够扩展的最大容量(capacity),我们暂且把这些变量成为切片的元数据。通常用 append 操作增长切片。当大小达到容量,会再分配,元数据字段也会被更新。当切片被 goroutine 并发访问,自然要上锁。
func ProcessAll(uuids []string) {
var myResults []string
var mutex sync.Mutex
safeAppend := func(res string) {
mutex.Lock()
myResults = append(myResults, res)
mutex.Unlock()
}
for _, uuid := range uuids {
go func(id string, results []string){
res := Foo(id)
safeAppend(res)
}(uuid, myResults) // slice read without holding lock
}
}
上面代码中,开发者认为第六行的锁保护了切片追加,足够避免数据竞争。但是数据竞争发生在第十四行切片作为参数传递给 goroutine,在这里是没有锁保护的。goroutine 的呼起导致切片的元数据字段从调用者(14 行)复制到被调用者(11 行)。鉴于切片是一种引用类型,将其传递(复制)给被调用者会引发数据竞争。但是切片和指针类型不同(元数据字段是按值复制的),所以数据竞争也不易察觉。
3 并发访问 Go 内置的 map 导致数据竞争
func processOrders(uuids []string) error {
var errMap = make(map[string]error)
for _, uuid := range uuids {
go func(uuid string) {
orderHandle, err := GetOrder(uuid)
if err != nil {
errMap[uuid] = err
return
}
//
}(uuid)
}
return combineErrors(errMap)
}
作为 Go 内置语言功能的哈希表(map)并发线程安全。如果多个 goroutine 同时读写同一个哈希表,数据竞争随之而来。我们观察到开发者普遍主观地假设哈希表中不同的记录可以被并发访问,源于 table[key]
语法,误解为访问不相交的元素。但是 map 不像数组或切片是稀疏的数据结构,访问一个元素可能导致其他元素同时被访问。我们甚至发现更多复杂的并发 map 访问数据竞争,由于同一个哈希表被传递给了深层的调用路径,而开发者却忘记了在这些调用路径中有异步的 goroutine 来变更哈希表。
虽然哈希表导致的数据竞争不是 Go 独有的,但以下原因使得 Go 中更容易发生数据竞争:
- Go 开发者相较于其他语言更频繁得使用 map 因为它是内置的。
- 哈希表访问语法和数组访问语法一样(不同于 Java 中的 get/put API),使得它更易于使用。即使访问不存在的元素也会返回默认值而不是产生错误,使得开发者往往掉以轻心。
4. Go 开发者经常在值传递方面犯错
值传递语义为 Go 推荐 因为它简化了逃逸分析并给增大变量被分配在栈上几率,减轻垃圾收集器的压力。
不像 Java 所有的对象都是引用类型;在 Go 中,对象可能是值类型(struct)或引用类型(interface)。语法上没有差别,这导致同步结构(sync.Mutex
和 sync.RWMutex
)的错误使用,在 Go 中都是值类型。如果一个函数创建一个互斥锁并通过值传递给多个 goroutine,那些 goroutine 的并发执行实际上是在操作不同的互斥锁对象,并不共享内部状态。锁并没有保护到共享内存。
var int a
func CriticalSection(m sync.Mutex) {
m.Lock()
a++
m.Unlock()
}
func main() {
mutex := sync.Mutex{}
// passes a copy of m to A
go CriticalSection(mutex)
go CriticalSection(mutex)
}
type Mutex struct {
// internal state
}
type (mtx *Mutex) Lock() {
// lock implementation
}
type (mtx *Mutex) Unlock() {
// unlock implementation
}
开发者没注意到 m.Lock()
作用在互斥锁的副本而非指针,因为 Go 语法中调用方法值和引用是一样的。如果没有这种透明,编译器就能探测出类型不匹配错误。
这种情况的反面是开发者实现的方法,其接收器(receiver)是一个指针而非结构的副本,这样在多个 goroutine 中调用该方法会意外地共享此结构体的内部状态,并非开发者的本意。
5. 混用消息传递和共享内存复杂化代码
|
|
开发者使用通道来发送信号和等待。future 由调用 Start()
方法启动,调用 Wait()
方法阻塞至 future 完成。Start()
方法创建了一个 goroutine,执行注册进 Feture 的一个函数并记录它的返回值(response
和 err
)。如第 6 行所示通过像通道 ch
发送消息,goroutine 通知 Wait()
方法 future 完成。对应地,Wait()
阻塞着等待来自通道的消息(11 行)。
如果上下文到期,相应的 case 将 ErrCancelled
记录至 Future 的 err
字段(14 行),与第 5 行写入相同的变量竞争。
6. Go 的组同步结构 sync.WaitGroup 提供了更多回旋的余地,但是 Add
/Done
方法的错误位置导致数据竞争
|
|
不像 C++ barrier、pthread,或 Java barrier、latch 结构,WaitGroup
中参与者数量在构造时并不确定,而是动态更新的。Add()
增加参与者数量,Wait()
阻塞至 Done()
被每个参与者调用一次。WaitGroup
在 Go 中到处使用。
开发者想要创建切片 itemIds
元素同等数量的 goroutine 来并发处理,每个 goroutine 都在 results
切片中记录下成功或失败状态,父函数在 12 行阻塞直到所有 goroutine 都完成。
要让这段代码工作正常,当 Wait()
在第 12 行被调用时,已注册的参与者数量必须等于 itemIds
的长度,这就意味着 wg.Add(1)
要被放在第 5 行,在 goroutine 被呼起前,但是第 7 行的 Add(1)
导致少于 itemIds
长度的数量被注册至 WaitGroup
。因此 Wait()
将会过早地解除阻塞,WaitGrpExample
函数开始从 results
切片开始读取时有些 goroutine 还在并发地写入同一个切片。
我们还发现过早地放置 wg.Done()
调用引发的数据竞争。
|
|
多 defer 语句是按 FIFO 顺序执行的。在这第 9 行的 wg.Wait()
在 doCleanup()
之前执行。第 10 行父 goroutine 访问 locationErr
时子 goroutine 可能仍在 doCleanup()
函数中写入 locationErr
。
7. 对于 Go 表格驱动的测试套件来说,并行跑测试往往会导致数据竞争,无论是生产还是测试代码
testing 是 Go 内置的功能。任何以 _test.go 为后缀的文件中带有 Test
前缀的函数都可以作为测试运行。如果测试代码调用 API testing.T.Parallel()
,将会与其他测试并发运行。数据竞争的根因有时在测试代码中有时在生产代码。另外,在单个 Test-
前缀的函数中,Go 开发者经常写下许多子测试并通过 Go 提供的工具包执行它们。Go 推荐用一种表格驱动的测试套件习语来编写和运行测试套件。我们的开发者习惯于在一个测试中编写数十个子测试,我们的系统会并行跑这些子测试。这个习语成为了测试套件文件的根源,开发者要么假设串行执行测试要么在大而复杂的测试中忘记共享对象的使用。当产品的 API 在编写时没有考虑线程安全但却被并行调用,违反了假设时也会出问题。
总结
综上所述,基于观察到的数据竞争,我们阐述了 Go 语言的范式使得在 Go 程序中引入竞争更容易。我们希望开发者引以为鉴,多关注并发编程的微妙之处。未来的编程语言设计者应该仔细权衡不同的语言特性和编码习语,降低产生神秘并发 bug 的可能性。