Go语言并发常见问题:A-Study-of-Real-World-Data-Races-in-Golang

前言

参考资料

  • 鸟窝,主要是看到这篇文章后发现了Uber的A Study of Real-World Data Races in Golang。
    • 本文在论文阅读的基础上加了很多可执行的例子,这些例子使用go run -race main.go应该都可以检出数据竞争。
  • 原论文
    • 感觉自己很多地方翻译的不是很好,省略了很多的内容,还是推荐阅读原文

数据竞争检测

data race的检测可以通过go build中加入-race来进行。详情见此文所举的例子

本文统一将data race译为数据竞争

摘要

Go作为将并发置为首位的语言,在现代基于微服务的系统中变得越来越受欢迎。同样,data race(数据竞争)也因此变得愈发普遍。本文在Uber的工业应用场景中进行了相关的实验,说明Go中语言习语和编写方式的细微差别使得Go非常容易受到数据竞争的影响。

动态的race detector可以识别(内部),但是面临着可伸缩性和flakiness的挑战。作者将自制的数据竞争检测器应用于Uber2100个微服务共计四千六百万条Go代码上,并最终检测2000个数据竞争,修复了其中的1000个。

1 Introduction

介绍了一些Go的优点,特别是其中适合于编写微服务的部分。 Go中不同goroutine之间的通信包括消息队列传输(channel)和共享内存。这里的共享内存应该指的是对同一个进程内数据的直接访问。

数据竞争的条件:

  1. 对于两个或者更多访问同一个数据对象(datum)的goroutine而言,至少有一个是写
  2. 这些goroutine之间没有顺序(比如channel或者lock所形成的偏序关系)

数据竞争的后果很严重,可能会导致最终的结果出现异常,并造成服务下线。 Go内置的数据竞争检测器采用了基于ThreadSanitizer的动态检测,包括lock-set和happens-before算法。其代价根据程序的大小变化,但是一般会造成内存占用增加5到10倍,执行时间增加2到10倍,并且编译时间会增加2倍。

本文介绍了使用Go的默认动态检测器来持续在uber的生产环境中检测数据竞争。尽管已经有了很多检测数据竞争的算法,但是这与在真实环境的设置中部署动态分析还有显著的差距。由于动态竞争检测的不确定性,将其作为连续检测的一部分进行集成是不切实际的;部署它作为事后检测过程又在不重复报告的同时确定正确的竞争拥有者这一点上引入了复杂性和挑战。我们根据实际情况精心设计了部署的选择。

我们使用了十万个Godanyuan测试来检验代码并检测数据竞争。在六个月内,连续监控系统检测了2000条以上的数据竞争,210个开发者使用790个补丁修复了其中多达1000个数据竞争,上下的正在被积极地解决。系统每天能从新引入的代码中检测到5个新的数据竞争。

分析结果显示除了常见的错误外,Go有着独特的方面来引入并发错误,包括

  • transparent capture-by-reference of free variables
  • named return variables
  • deferred functions
  • ability to mix shared memory with message passing
  • indistinguishable value vs pointer semantics
  • extensive use of thread-unsafe built-in map
  • flexible gropu synchronization
  • confusing slice 并且与Go简单的使用goroutine来进行并发的特性结合,使得Go很容易出现数据竞争。 (因此本文主要讨论的是由Go的语法特点引起的数据竞争)

贡献:

  1. 讨论Go的并发特点。
  2. 明确在生产系统中部署动态数据竞争检测的技术难点。
  3. 对我们Go程序中的数据竞争模式进行了细致的分析。

2 Concurrency in Go Services

  • Observation 1:Go的开发者与Java开发者相比,用了更多的并发和同步结构。
  • Observation 2: 使用Go进行微服务编程的开发人员在运行时使用的并发性明显多于其他语言。

3 Deploying Dynamic Data Race Detector

部署部分不感兴趣,没看。

4 Observations of Data Races in Go

此节列举了常见的数据竞争类型,即本文的重点部分

4.2 Races due to Transparent Capture-by-Reference

  • Observation 3:对自由变量的透明引用捕获是数据竞争的常见原因(recipe,没get到这个点)

Nested function,或者说闭包,将所有自由变量的引用透明地捕获了。与C++不同,Go的闭包没有说明捕获了哪些自由变量;也不像是java一样捕获的只是值。更经常的是Go的开发者在goroutine中使用闭包,这使得对这些自由变量的访问顺序变为未知。

4.2.1 Loop index variable capture 循环变量引用捕获

很经典的错误,但是说实话,该错的时候还是会错。

for _, job := range jobs{
    go func(){
        ProcessJob(job)
    }()
}

可执行的例子:

package main

import (
	"fmt"
	"sync"
)

func main() {
	jobs := []string{"test1", "test2", "test3"}
	var wg sync.WaitGroup
	for _, job := range jobs { // 写的位置
		wg.Add(1)
		go func() {
			defer wg.Done()
			fmt.Println(job) // 读的位置
		}()
	}
	wg.Wait()
}

结果为:

test3
==================
WARNING: DATA RACE
Read at 0x00c00009c210 by goroutine 7:
  main.main.func1()
      /Users/wty/go/src/vegetaTest/main.go:15 +0xa4

Previous write at 0x00c00009c210 by main goroutine:
  main.main()
      /Users/wty/go/src/vegetaTest/main.go:11 +0x119

Goroutine 7 (running) created at:
  main.main()
      /Users/wty/go/src/vegetaTest/main.go:13 +0x1fc
==================
test3
test3
Found 1 data race(s)
exit status 66

具体来说,在13行启动的goroutine发生了数据竞争,读写冲突,数据在读前被改变了。写的位置为11行,读的位置为15行。

解决方法也很简单,显式地传值即可:

func main() {
	jobs := []string{"test1", "test2", "test3"}
	var wg sync.WaitGroup
	for _, job := range jobs {
		wg.Add(1)
		go func(j string) {
			defer wg.Done()
			fmt.Println(j)
		}(job)
	}
	wg.Wait()
}

或者这样:

var wg sync.WaitGroup

func printStr(j string) {
	defer wg.Done()
	fmt.Println(j)
}

func main() {
	jobs := []string{"test1", "test2", "test3"}
	for _, job := range jobs {
		wg.Add(1)
		go printStr(job)
	}
	wg.Wait()
}

4.2.2 Idiomatic err variable capture

err变量捕获。对于Go来说,一般会将函数的最后一个返回值作为错误变量err。 一般来说,Go不会总是创建新的错误变量,如y的返回值中的err和z的返回值中的err。但是这使得两个err之间会发生数据竞争。

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{
    ...
}

基于此构建例子:

package main

import (
	"errors"
	"fmt"
)

func Foo() (int, error) {
	return 0, nil
}

func Bar() (int, error) {
	return 1, errors.New("Bar")
}

func Baz() (int, error) {
	return 2, errors.New("Baz")
}

func main() {
	x, err := Foo()
	if err != nil {
		fmt.Println(x, err)
	}
	go func() {
		var y int
		y, err = Bar() // 写的位置
		if err != nil {
			fmt.Println(y, err)
		}
	}()

	var z int
	z, err = Baz() // 写的位置
	if err != nil {
		fmt.Println(z, err)
	}
}

此处的原因是闭包内将err捕获,从而导致内外两个地方对于err的赋值可能发生数据竞争。

4.2.3 Named return variable capture

Go具有着具名返回值这一语法糖,但它会隐式地对变量进行赋值。 一般来说,如果函数体比较长,会推荐使用具名返回值而不是直接返回。 当变量被闭包捕获时就会发生数据竞争,包括被普通的捕获或者是被defer捕获。

作者给的Named return variable variable capture例子

func NamedReturnCallee() (result int){
    result = 10
    if ...{
        return // 等价于 return 10
    }
    go func(){
        ... = result // read result,10/20
    }()
    return 20 // 等价于 result = 20,发生数据竞争
}

func Caller(){
    retVal := NamedReturnCallee()
}

基于此制作出的具有数据竞争的可运行示例

package main

import (
	"fmt"
	"math/rand"
	"sync"
)

func getRand() int {
	maxN := 100
	minN := 0
	x := rand.Intn(maxN-minN) + minN
	return x
}

func NamedReturnCallee() (result int) {
	result = 10
	randNum := getRand()
	if randNum < 50 {
		return // 等价于 return 10
	}
	go func() {
		data := result // read result,10/20。这个读取是非常危险的
		fmt.Println(data)
	}()
	return 20 // 等价于 result = 20,发生数据修改,数据竞争(读写冲突)
}

func Caller() {
	retVal := NamedReturnCallee()
	fmt.Println("return retVal:", retVal)
}

func main() {
	wg := new(sync.WaitGroup)
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			Caller()
			defer wg.Done()
		}()
	}
	wg.Wait()
}

执行go run -race main.go之后,系统提示发生读写冲突,读的位置为新建的goroutine内部,写的位置为return 20。 这个数据竞争的问题在于,可能会没有意识到具名返回值的具体返回本质上是对该具名变量的赋值,从而造成问题。

作者给的Named return variable capture with a defer return例子

func Redeem(request Entity) (resp Response, err error){
    defer func(){
        resp, err = c.Foo(request,err)
    }()
	err = CheckRequest(request)
	... // err check but no return
	go func(){
		ProcessRequest(request, err!=nil)
	}()
	return // the defer functions return after here
}

基于此构建出的可运行代码,数据竞争问题为err变量的读写冲突。

package main

import (
	"errors"
	"fmt"
	"sync"
)

type testReq struct {
	name string
	id   int
}

type testRsp struct {
	name string
	id   int
}

func foo(req testReq, err error) (testRsp, error) {
	rsp := testRsp{name: req.name, id: req.id}
	return rsp, nil
}

func CheckRequest(req testReq) error {
	if req.name != "" {
		return nil
	}
	return errors.New("empty request name")
}

func ProcessRequest(req testReq, isNil bool) {
	if !isNil {
		fmt.Println("ProcessRequest:", isNil)
	}
}

func redeem(request testReq) (resp testRsp, err error) {
	resp = testRsp{name: "", id: 0}
	defer func() {
		resp, err = foo(request, err) // 此处对err的值进行写入
	}()
	err = CheckRequest(request)
	if err != nil { // err check but no return
		return
	}
	go func() {
		ProcessRequest(request, err != nil) // 此处对err的值进行读取
	}()
	return // the defer functions return after here
}

func main() {
	wg := new(sync.WaitGroup)
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			req := testReq{name: "test", id: i}
			resp, err := redeem(req)
			fmt.Println(i, resp, err)
			defer wg.Done()
		}()
	}
	wg.Wait()
}

该代码的问题在于defer函数执行的时候,最后的goroutine并不一定会结束,从而导致读写冲突。

4.3 Data Races due to Slices

Observation 4:slices是让人困惑的类型,会导致微妙且难以诊断的数据竞争。 这也是出现频率较高的数据竞争情况。

作者给的例子:

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)
	}
}

基于此构建的可执行程序

package main

import (
	"sync"
)

func Foo(id string) string {
	return id + "test"
}

func ProcessAll(uuids []string) {
	var myResults []string
	var mutex sync.Mutex
	safeAppend := func(res string) {
		mutex.Lock()
		myResults = append(myResults, res) // 此处对myReults进行了写操作(新值/扩容)
		mutex.Unlock()
	}

	for _, uuid := range uuids {
		go func(id string, results []string) {
			res := Foo(id)
			safeAppend(res)
		}(uuid, myResults) // 此处传入了myResults slices,读取slices的三个meta data。但这三个meta data可能已经在赋值的过程中发生了修改.
	}
}

func main() {
	uuids := []string{"uuid1", "uuid3", "uuid9"}
	wg := new(sync.WaitGroup)
	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			ProcessAll(uuids)
			defer wg.Done()
		}()
	}
	wg.Wait()
}

代码的问题在于对于slices myResults在append于后续传值这两个地方发生了读写冲突。 论文作者建议对这部分代码的修改为(选择):

  1. 不要在ProcessAll最后的go func中传myResults的值,而是传引用。
  2. 不要在safeAppend闭包中捕获myResults,而是应该在持有锁的时候直接解引用指针。

4.4 Data Races on Thread-Unsafe Map

Observation 5: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 // 写写冲突,即使uuid一般是不同的,它们也有可能哈希到相同的内存空间或者因为哈希表的实现导致访问上的相互关联
				return
			}
			// ...
		}(uuid)
	}
	return combineErrors(errMap)
}

这个就比较常见了,因此没有构造例子。在目前的Go版本中在goroutine中共享map应该是无法通过编译的。 相较于其他语言(比如Java),Go代码更经常地使用哈希表,且哈希表的访问可以通过数组风格,因此更容易出现问题。

4.5 Mistakes due to Pass-by-Value vs. Pass-by-Reference in Go

Observation 6:传值在Go中更被推荐,因为它可以简化逃逸分析,并让变量更可能被分配到栈上,从而降低GC的压力。开发者常常在变量传值(或者方法传值)上犯错,从而导致不一般的数据竞争。

由于该例子比较简单,直接改写了作者给的示例代码使其可执行

package main

import (
	"fmt"
	"sync"
)

var a int

func CriticalSection(m sync.Mutex) {
	m.Lock()
	a++
	m.Unlock()
}
func main() {
	a = 0
	mutex := sync.Mutex{}
	// passes a copy of m to A.
	go CriticalSection(mutex)
	go CriticalSection(mutex)
	fmt.Println(a)
}

这里的问题是对于mutex采用了copy而不是传引用,这使得在两次调用中实际上使用的是不同的mutex。 因此a++部分发生了数据竞争(尽管最终的结果可能是对的) 如果执行go vet可以发现对sync.Mutex进行拷贝的告警。

对于mutex是应该使用指针还是值一直有着讨论。如so上讨论的结果,如果需要共享状态(如上面的代码一样),则必须使用指针,或者让对应方法的接收器变为指针形式。否则应该新初始化mutex变量以避免共享锁结构。 无论如何,mutex.Lock不应该被复制。

4.6 Mixing Shared Memory with Message Passing

Observation 7:将channel和共享内存混用会使得代码变得复杂,并且可能导致数据竞争问题。

作者提供的例子

func (f *Future) Start(){
	go func(){
		resp, err := f.f() // invoke a registered function
		f.response = resp
		f.err = err
		f.ch <- 1 // may block forever!
	}()
}

func (f *Future) Wait(ctx context.Context) error{
	select{
		case <-f.ch:
			return nil
		case <-ctx.Done():
			f.err = ErrCancelled
			return ErrCancelled
	}
}

基于上面的函数构造出以下代码

package main

import (
	"context"
	"errors"
	"fmt"
	"time"
)

type Future struct {
	response string
	err      error
	ch       chan int
}

var ErrCancelled error = errors.New("error cancelled")

func (f Future) f() (string, error) {
	return "", nil
}

func (f *Future) Start() {
	go func() {
		resp, err := f.f() // invoke a registered function
		f.response = resp
		f.err = err // write: init
		f.ch <- 1
	}()
}

func (f *Future) Wait(ctx context.Context) error {
	select {
	case <-f.ch:
		return nil
	case <-ctx.Done():
		f.err = ErrCancelled // write: context canceled
		return ErrCancelled
	}
}

func Run(f *Future) {
	f.Start()
	ctx := context.Background()
	ctx2, _ := context.WithTimeout(ctx, time.Duration(10)*time.Microsecond)
	err := f.Wait(ctx2)
	if err != nil {
		fmt.Println(err)
	}
}

func main() {
	f := Future{}
	f.ch = make(chan int)
	for i := 0; i < 1; i++ {
		go Run(&f)
	}

}

论文作者对于该部分的数据竞争说明为在context超时的时候,ctx.Done对应的err会被赋值为ErrCancelled, 这个操作会与初始化时修改err部分的代码有写冲突。 数据竞争很难构造出来,大概是这样子。但我不确定这是否是作者期望的数据竞争。

wty@TIANYANGWU-MB1 vegetaTest % go run -race main.go
==================
WARNING: DATA RACE
Write at 0x00c0000a0190 by goroutine 7:
  main.(*Future).Wait()
      /Users/wty/go/src/vegetaTest/main.go:36 +0x144
  main.Run()
      /Users/wty/go/src/vegetaTest/main.go:45 +0x65
  main.main·dwrap·1()
      /Users/wty/go/src/vegetaTest/main.go:55 +0x39

Previous read at 0x00c0000a0190 by goroutine 8:
  runtime.racereadrange()
      <autogenerated>:1 +0x1b

Goroutine 7 (running) created at:
  main.main()
      /Users/wty/go/src/vegetaTest/main.go:55 +0xdd

Goroutine 8 (running) created at:
  main.(*Future).Start()
      /Users/wty/go/src/vegetaTest/main.go:23 +0x90
  main.Run()
      /Users/wty/go/src/vegetaTest/main.go:42 +0x30
  main.main·dwrap·1()
      /Users/wty/go/src/vegetaTest/main.go:55 +0x39
==================
error cancelled
Found 1 data race(s)
exit status 66

如果context没有超时的话,这部分代码是没有数据竞争问题的。不过可能会有其他问题,即Future的channel有可能永久阻塞。

4.7 Incorrect Use of Flexbile Group Synchronization

Observation 8:Go在sync.WaitGroup中提供了更多的余地,其参与者的数量是动态的,在定义时没有决定。wg中Add与Done方法的不正确使用会导致数据竞争。

Listing 10:作者提供的例子

func WaitGrpExample(itemIds []int) int{
	wg sync.WaitGroup
	results := make([]int,len(itemIds))
	for i:=0; i<len(itemIds); i++{
		go(idx int){
			wg.Add(1) // incorrect wg.Add placement
			results[idx] = ...
			wg.Done()
		}(i)
	}
	wg.Wait() // waits for the participants added so far.
	... = results
}

这个例子的问题比较明显,wg.Add应该放在主goroutine中。如代码这样的写法可能会导致程序提前结束,使得执行到后面的代码时循环次数不足len(itemIds)次。并且导致对于results的读写争用。

基于此代码构建示例:

package main

import (
	"fmt"
	"sync"
)

func WaitGrpExample(itemIds []int) int {
	var wg sync.WaitGroup
	results := make([]int, len(itemIds))
	for i := 0; i < len(itemIds); i++ {
		go func(idx int) {
			wg.Add(1) // incorrect wg.Add placement
			results[idx] = idx // 数据竞争:write at
			wg.Done()
		}(i)
	}
	wg.Wait() // waits for the participants added so far.
	otherResult := results
	return otherResult[len(results)-1] // 数据竞争:Previous read
}

func main() {
	itemIds := []int{1, 2, 3, 4, 5, 6, 7, 8, 9}
	fmt.Println(WaitGrpExample(itemIds))
}

4.8 Parallel Testing Idiom

Observation 9:在Go的基于表格驱动的测试中并行执行测试可能会导致数据竞争,有时在测试代码中,有时在测试所调用的业务代码中。

testing.T.Parallel()可以使得测试并行执行,而测试中可以有多个子测试。这些测试之间的并行可能带来问题。 作者并没有提供示例代码,不过应该与其他可能出现数据竞争的代码是相同的。

4.9 Incorrect or Missing Mutual Exclusion

Observation 10:对互斥源语的不正确使用会导致数据竞争。

这并不是Go独有的问题,而且同样是我们代码中数据竞争最经常出现的原因。

4.9.1 Mutating shared data in a Reader-lock-protected critical section

在互斥工作区中使用读写锁,在只加读锁的情况下对工作区进行写入,修改了共享的数据。 并发的reader可能会同时执行写的部分,造成写冲突。更严重的是在Accept部分可能会接受多次,导致网络IO操作被执行多次。

func (g *HealthGate) updateGate(){
	g.mutex.RLock()
	defer g.mutex.RUnlock()
	// ... several read-only operations ...
	if ...{
		g.ready = true // Concurrent writes.
		g.gate.Accept() // More than one Accept().
	}
}

4.9.2 Other forms for incorrect mutual exclusion

有很多互斥结构被不正确使用的例子。其中比较难察觉到的错误是部分互斥(partial mutual exclusion),即开发者在一个地方使用了锁,而在另外一个访问共享变量的地方忘记使用它。 而在一些情况下,使用者使用了锁结构,但是过早的调用了解锁(unlock),导致一些对于共享变量的访问落在关键区之外。 我们也观察到对于sync.Atomic包的部分使用,即在写共享变量的时候调用,但是忘记在读该共享变量的时候使用。

4.10 Summary of Findings

作者总结了上面不同类型错误所导致的数据竞争在代码库中的数量。表3中的原因与Go的特有语法无关,因此在本文中讨论的比较少。从表2的数据可以发现,对slices的并发读写造成的数据竞争是最多的。

表2

表3

4.11 Threats to Validity

上述错误均是基于对Uber的Go仓库的分析结果,可能与其他地方的数据竞争情况不同。同时这些数据竞争都是由动态竞争检测器检测出来的,由于代码覆盖率等问题可能会遗漏部分类型的数据竞争。

总结

总体来说,Go中的数据竞争总是由两个以上的goroutine之间存在至少一个对共享变量的写操作造成的,且写操作之间是没有偏序顺序(partial order)。由于Go特有的语法,导致变量在无意间发生了赋值与修改,从而引起数据竞争。

0%