Go 数据竞争

2021-09-15 Golang Go 进阶

# 一、数据竞争的检测

数据竞争(data race)是两个或多个 goroutine 访问同一个资源(如变量或数据结构),并尝试对该资源进行读写而不考虑其他 goroutine。这种类型的代码会出现非常随机的 bug,通常需要大量的日志记录和运气才能找到这种类型的 bug。

那我们有没有什么办法检测它呢?Go 官方早在 1.1 版本就引入了数据竞争的检测工具,我们只需要在执行测试或者是编译的时候加上 -race 的 flag 就可以开启数据竞争的检测:

go test -race ./...
go build -race
1
2

注意

  • 不建议在生产环境 build 的时候开启数据竞争检测,因为这会带来一定的性能损失(一般内存 5 ~ 10 倍,执行时间 2 ~ 20 倍),当然,必须要 debug 的时候除外;
  • 建议在执行单元测试时始终开启数据竞争的检测。

# 二、竞争检测工具的使用

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup
var counter int

func main() {
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go routine()
	}
	wg.Wait()
	fmt.Printf("Final Counter: %d\n", counter)
}

func routine() {
	value := counter
	value++
	counter = value
	wg.Done()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

上面这段代码其中三次的执行结果分别是:

Final Counter: 947

Final Counter: 961

Final Counter: 969

为什么会导致这样的结果呢,是因为每一次 for 循环中,我们都使用 go routine() 启动了一个 goroutine,它们都对同一个变量进行了读写操作,但是我们并没有控制它的执行顺序,因此每次执行结果都不同。

可以发现,写出这种代码时上线后如果出现 bug 会非常难定位,因为你不知道到底是哪里出现了问题,所以我们就要在测试阶段就结合 data race 工具提前发现问题。

我们执行以下命令:

go run -race ./main.go
1

运行结果如下:

==================

WARNING: DATA RACE

Read at 0x000001232c90 by goroutine 8:

main.routine()

  /Users/lichangao/Study/Go/go_learning/src/main/main.go:21 +0x3e

Previous write at 0x000001232c90 by goroutine 7:

main.routine()

  /Users/lichangao/Study/Go/go_learning/src/main/main.go:23 +0x5a

Goroutine 8 (running) created at:

main.main()

  /Users/lichangao/Study/Go/go_learning/src/main/main.go:14 +0x68

Goroutine 7 (finished) created at:

main.main()

  /Users/lichangao/Study/Go/go_learning/src/main/main.go:14 +0x68

==================

Final Counter: 999

Found 1 data race(s)

exit status 66

这个结果非常清晰的告诉了我们在 21 行这个地方我们有一个 goroutine 在读取数据,但是呢,在 23 行这个地方又有一个 goroutine 在写入,所以产生了数据竞争。

然后下面分别说明这两个 goroutine 是什么时候创建的,以及当前是否在运行当中。

# 三、典型案例

# 3.1 案例一:在循环中启动 goroutine 引用临时变量

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func() {
			fmt.Println(i) // Not the 'i' you are looking for.
			wg.Done()
		}()
	}
	wg.Wait()
}
1
2
3
4
5
6
7
8
9
10
11

这段代码最有可能会输出 5 个 5,因为在 for 循环的 i++ 会执行的快一些,所以在最后打印的结果都是 5。

但是这里是有数据竞争的,在新启动的 goroutine 当中读取 i 的值,在 main 中写入,导致出现了 data race,这个结果应该是不可预知的,因为我们不能假定 goroutine 中 print 就一定比外面的 i++ 慢,习惯性的做这种假设在并发编程中是很有可能会出问题的。

func main() {
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func(j int) {
			fmt.Println(j) // Good. Read local copy of the loop counter.
			wg.Done()
		}(i)
	}
	wg.Wait()
}
1
2
3
4
5
6
7
8
9
10
11

这个要修改也很简单,只需要将 i 作为参数传入即可,这样每个 goroutine 拿到的都是拷贝后的数据。

# 3.2 案例二:意外共享变量

package main

import (
	"os"
)

func main() {
	ParallelWrite([]byte("xxx"))
}

// ParallelWrite writes data to file1 and file2, returns the errors.
func ParallelWrite(data []byte) chan error {
	res := make(chan error, 2)
	f1, err := os.Create("file1")
	if err != nil {
		res <- err
	} else {
		go func() {
			// This err is shared with the main goroutine,
			// so the write races with the write below.
			_, err = f1.Write(data)
			res <- err
			f1.Close()
		}()
	}
	f2, err := os.Create("file2") // The second conflicting write to err.
	if err != nil {
		res <- err
	} else {
		go func() {
			_, err = f2.Write(data)
			res <- err
			f2.Close()
		}()
	}
	return res
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

我们使用 go run -race ./main.go 执行,可以发现这里报错的地方是,19 行和 24 行,有 data race,这里是因为共享了 err 这个变量。

==================

WARNING: DATA RACE

Write at 0x00c00009c1e0 by main goroutine:

main.ParallelWrite()

  /Users/lichangao/Study/Go/go_learning/src/main/main.go:24 +0x1dd

main.main()

  /Users/lichangao/Study/Go/go_learning/src/main/main.go:6 +0x84

Previous write at 0x00c00009c1e0 by goroutine 7:

main.ParallelWrite.func1()

  /Users/lichangao/Study/Go/go_learning/src/main/main.go:19 +0x94

Goroutine 7 (finished) created at:

main.ParallelWrite()

  /Users/lichangao/Study/Go/go_learning/src/main/main.go:16 +0x336

main.main()

  /Users/lichangao/Study/Go/go_learning/src/main/main.go:6 +0x84

==================

修改的话只需要在两个 goroutine 中使用新的临时变量就行了:

...
_, err := f1.Write(data)
...
_, err := f1.Write(data)
...
1
2
3
4
5

注意

Go 中初始化变量的时候如果在同一个作用域下,如下方代码,这里使用的 err 其实是同一个变量,只是 f1 和 f2 不同,具体可以看 Effective Go: Redeclaration and reassignment (opens new window) 的内容。

f1, err := os.Create("a")
f2, err := os.Create("b")
1
2

# 3.3 案例三:未受保护的全局变量

如果有多个 goroutine 调用下面这段代码,会导致 service map 的数据竞争。

var service map[string]net.Addr

func RegisterService(name string, addr net.Addr) {
	service[name] = addr
}

func LookupService(name string) net.Addr {
	return service[name]
}
1
2
3
4
5
6
7
8
9

要使这段代码安全运行,可以通过 mutex 来控制访问:

var (
	service   map[string]net.Addr
	serviceMu sync.Mutex
)

func RegisterService(name string, addr net.Addr) {
	serviceMu.Lock()
	defer serviceMu.Unlock()
	service[name] = addr
}

func LookupService(name string) net.Addr {
	serviceMu.Lock()
	defer serviceMu.Unlock()
	return service[name]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 3.4 案例四:未受保护的基本类型变量

数据竞争也可能发生在基本类型的变量(bool、int、int64 等)上,如下所示:

type Watchdog struct{ last int64 }

func (w *Watchdog) KeepAlive() {
	w.last = time.Now().UnixNano() // First conflicting access.
}

func (w *Watchdog) Start() {
	go func() {
		for {
			time.Sleep(time.Second)
			// Second conflicting access.
			if w.last < time.Now().Add(-10*time.Second).UnixNano() {
				fmt.Println("No keepalives for 10 seconds. Dying.")
				os.Exit(1)
			}
		}
	}()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

这里可以用 sync/atomic 包来解决:

type Watchdog struct{ last int64 }

func (w *Watchdog) KeepAlive() {
	atomic.StoreInt64(&w.last, time.Now().UnixNano())
}

func (w *Watchdog) Start() {
	go func() {
		for {
			time.Sleep(time.Second)
			if atomic.LoadInt64(&w.last) < time.Now().Add(-10*time.Second).UnixNano() {
				fmt.Println("No keepalives for 10 seconds. Dying.")
				os.Exit(1)
			}
		}
	}()
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

# 3.5 案例五:非同步的发送和关闭操作

同一 channel 上的非同步发送和关闭操作也可能是竞争条件:

c := make(chan struct{}) // or buffered channel

// The race detector cannot derive the happens before relation
// for the following send and close operations. These two operations
// are unsynchronized and happen concurrently.
go func() { c <- struct{}{} }()
close(c)
1
2
3
4
5
6
7

根据 Go 的内存模型,一个 channel 上的发送会在该 channel 的相应接收完成之前发生。为了同步发送和关闭操作,可以使用一个接收操作,保证在关闭之前完成发送。

c := make(chan struct{}) // or buffered channel

go func() { c <- struct{}{} }()
<-c
close(c)
1
2
3
4
5

# 3.6 案例六:没有安全的 data race

package main

import "fmt"

type IceCreamMaker interface {
	// Hello greets a customer
	Hello()
}

type Ben struct {
	name string
}

func (b *Ben) Hello() {
	fmt.Printf("Ben says, \"Hello my name is %s\"\n", b.name)
}

type Jerry struct {
	name string
}

func (j *Jerry) Hello() {
	fmt.Printf("Jerry says, \"Hello my name is %s\"\n", j.name)
}

func main() {
	var ben = &Ben{"Ben"}
	var jerry = &Jerry{"Jerry"}
	var maker IceCreamMaker = ben

	var loop0, loop1 func()

	loop0 = func() {
		maker = ben
		go loop1()
	}

	loop1 = func() {
		maker = jerry
		go loop0()
	}

	go loop0()

	for {
		maker.Hello()
	}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

以上代码最后输出的结果可能有以下情况:

...

Ben says, "Hello my name is Jerry"

...

Jerry says, "Hello my name is Ben"

...

这是因为我们在对 maker 赋值的时候并不是原子的,在 Go 内存模型:Happens Before - 机器字 中我们讲到过,只有对单个机器字进行赋值的时候才是原子的,虽然这个看上去只有一行,但是 interface 在 Go 中其实是一个结构体,它包含了 type 和 data 两个部分,如下定义,所以它的赋值也不是原子的,会出现问题。

type interface struct {
	Type uintptr     // points to the type of the interface implementation
	Data uintptr     // holds the data for the interface's receiver
}
1
2
3
4

这个案例的可怕点在于,这两个结构体的内存布局一模一样,所以出现错误也不会 panic 退出,如果在其中一个结构体里面再加入一个 string 的字段,去读取就会导致 panic,这种错误在线上实在太难发现了,而且很有可能会很致命。因此,不要假设安全的 data race。

Last Updated: 2023-01-28 4:31:25