Data Race概念

Data Race (数据竞争),是并发系统中最常见和最难调试的错误类型之一,当两个线程(协程)同时访问同一个变量并且至少其中一次访问是写入时,就会发生 Data Race 。Java 内存模型(JMM)定义了明确的 Happens-Before 规则和提供 Volatile 机制来初步保证程序的执行顺序,Go内存模型(GMM)同样也有相同的规则定义,此外 Golang 还提供了一种检测 Data Race 的机制,帮助开发者更好的分析并发代码的正确性。

go在1.1版本时支持data race 检测

Data Race 和 Race Condition 的区别: 这两个概念非常容易混淆,stackoverflow上也有讨论这个问题,其实我认为这篇文章很好的区别了它们,所以我进行了翻译

检测 Data Race

使用 -race 参数可以用来检测这种竞态:

$ go test -race mypkg    // to test the package
$ go run -race mysrc.go  // to run the source file
$ go build -race mycmd   // to build the command
$ go install -race mypkg // to install the package
func main() {
	c := make(chan int)
	m := make(map[string]int)
	go func() {
		m["a"] = 1 // 访问map冲突
		c <- 1
	}()
	m["a"] = 2 // 访问map冲突
	<-c
	for k, v := range m {
		fmt.Printf("key = %v, val = %v\n", k, v)
	}
}

示例程序中,两个 goroutine 同时读写 map 存在竞态,可能造成数据不正确的情况,但是错误难以发现,通过执行时添加 -race 选择可以检测:

 ➜  datarace git:(main) ✗ go run -race race_demo.go 
==================
WARNING: DATA RACE
Write at 0x00c00007e180 by goroutine 6:
  runtime.mapassign_faststr()
      /Users/hank/software/go1.20.5/src/runtime/map_faststr.go:203 +0x0
  main.main.func1()
      /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/race_demo.go:10 +0
x50

Previous write at 0x00c00007e180 by main goroutine:
  runtime.mapassign_faststr()
      /Users/hank/software/go1.20.5/src/runtime/map_faststr.go:203 +0x0
  main.main()
      /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/race_demo.go:13 +0
x13a

Goroutine 6 (running) created at:
  main.main()
      /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/race_demo.go:9 +0x
11d
==================
==================
WARNING: DATA RACE
Write at 0x00c00010e7d8 by goroutine 6:
  main.main.func1()
      /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/race_demo.go:10 +0
x5c

Previous write at 0x00c00010e7d8 by main goroutine:
  main.main()
      /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/race_demo.go:13 +0
x146

Goroutine 6 (running) created at:
  main.main()
      /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/race_demo.go:9 +0x
11d
==================
key = a, val = 1
Found 2 data race(s)
exit status 66

运行后输出如上的报告,可以看到 10、13 行两个 goroutine 中存在竞态。

支持的选项

可以通过 GORACE 环境变量配置选项,包括:

  • log_path: 默认为 stderr, Race 检测器将其报告写入名为 log_path.pid 的文件。特殊值 stdoutstderr 会将报告分别写入标准输出和标准错误。
  • exitcode (默认 66 ):检测到竞态后的退出码。
  • strip_path_prefix (默认 "" ):从所有报告的文件路径中删除此前缀,以使报告更加简洁。
  • history_size (默认 1 ):每个 goroutine 的内存访问历史记录是 32K * 2**history_size elements 。增加此值可以避免报告中出现“failed to restore the stack” 错误,但代价是增加内存使用量。
  • halt_on_error (默认 0 ):控制程序在报告第一次 data race 后是否退出。
  • atexit_sleep_ms (默认 1000 ):退出之前在主 goroutine 中休眠的毫秒数。

例如,运行时添加参数设置:

GORACE="log_path=./log strip_path_prefix=Goroutine" go run -race race_demo.go

运行后会在当前目录创建 log.xxx 的日志文件,并记录 race 报告,过滤掉 Goroutine 开头的行。

常见的data race存在于循环变量中,如示例代码中的 counter.go:

func race() {
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func() {
			fmt.Print(i)
			wg.Done()
		}()
	}
	wg.Wait()
}

在 go1.20 及之前的版本中,goroutine中读取的i值都是5,所以程序输出 55555。go1.20之后的版本已经解决了这个问题。

go1.21之前一般通过给 goroutine 传递参数来复制循环变量解决该问题:

func fixRace() {
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func(j int) {
			fmt.Print(j) // Good. Read local copy of the loop counter.
			wg.Done()
		}(i) // copy var i to j
	}
	wg.Wait()
}

执行测试代码难以全面的检测竞态代码,而且只能在运行时检测到,可以在编译时添加 -race 选项,然后运行可执行程序来检测:

go build -race .
./count

输出如下:

==================
WARNING: DATA RACE
Read at 0x00c00001a138 by goroutine 6:
  main.race.func1()
      /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/count/counter.go:18 +0x44

Previous write at 0x00c00001a138 by main goroutine:
  main.race()
      /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/count/counter.go:16 +0xa9
  main.main()
      /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/count/counter.go:9 +0x24

Goroutine 6 (running) created at:
  main.race()
      /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/count/counter.go:17 +0x8d
  main.main()
      /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/count/counter.go:9 +0x24
==================
435Found 1 data race(s)

常见的 Data Race

循环计数器上的竞争

如前边的 counter.go 就是这种情况。

意外共享变量

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

由于 err 意外地共享给了 ParallelWrite 方法所在的 goroutine,导致data race,解决方案就是重新创建变量:

...
_, err := f1.Write(data)
...
_, err := f2.Write(data)
...

未受保护的全局变量

var (
	service = make(map[string]net.Addr)
)

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

func UnsafeLookupService(name string) net.Addr {
	return service[name]
}

func main() {
	key := "net"
	_, ipv4Net, err := net.ParseCIDR("192.0.2.1/24")
	if err != nil {
		log.Fatal(err)
	}

	go func() {
		UnsafeRegisterService(key, ipv4Net)
	}()
	time.Sleep(time.Second)
	ret := UnsafeLookupService(key)
	fmt.Println(ret)
}

很明显 map 被共享读写,这是不安全的,存在竞态,需要加锁或者使用 sync.Map 来解决,具体可以看文末的示例代码。

原始未受保护的变量

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

这种情况与上一条类似,只是data race发生在原始类型的变量上(boolintint64等),这些问题是由内存访问的非原子性、编译器优化的干扰或访问处理器内存的重新排序问题引起的。解决办法是使用 sync.Mutex 加锁,或者使用 sync/atomic 原子包。

不同步的发送和关闭操作

如本示例所示,同一通道上不同步的发送和关闭操作也可能存在 Data Race:

c := make(chan struct{})

// 检测器无法推导出以下发送和关闭操作的先后顺序。这两个操作是异步操作。
go func() { c <- struct{}{} }()
close(c)

根据 Go 内存模型,通道上的发送发生在该通道的相应接收完成之前。要同步发送和关闭操作,请使用接收操作来保证发送在关闭之前完成:

c := make(chan struct{})

go func() { c <- struct{}{} }()
<-c
close(c)

Data Race 检测要求和成本

竞争检测器需要启用 cgo,并且在非 Darwin 系统上需要安装 C 编译器。竞争检测器支持 linux/amd64 、 linux/ppc64le 、 linux/arm64 、 linux/s390x 、 freebsd/amd64 、 netbsd/amd64 、 darwin/amd64 、 darwin/arm64 和 windows/amd64 。

在 Windows 上,竞争检测器运行时对安装的 C 编译器版本敏感;从 Go 1.21 开始,使用 -race 构建程序需要一个包含版本 8 或更高版本的 mingw-w64 运行时库的 C 编译器。您可以通过使用参数 –print-file-name libsynchronization.a 调用 C 编译器来测试它。较新的兼容 C 编译器将打印该库的完整路径,而较旧的 C 编译器只会回显该参数。

竞争检测的成本因程序而异,但对于典型的程序,内存使用量可能会增加 5-10 倍,执行时间可能会增加 2-20 倍。

目前,竞争检测器为每个 defer 和 recover 语句分配额外的 8 个字节。这些额外的分配在 goroutine 退出之前不会被恢复。这意味着,如果您有一个长时间运行的 goroutine 定期发出 defer 和 recover 调用,则程序内存使用量可能会无限增长。这些内存分配不会显示在 runtime.ReadMemStats 或 runtime/pprof 的输出中。

Benign Data Race

Benign Data Race即良性数据竞争,这是一个非常具有争议的话题。这篇文章讨论了良性数据竞争,作者提到,在 java文档指出 ,良性数据竞争提高了程序性能,也保证了并发安全性:

Some multi-threaded applications intentionally allow data-races in order to get better performance. A benign data-race is an intentional data-race whose existence does not affect the correctness of the program. 
一些多线程应用程序故意允许数据竞争以获得更好的性能。良性数据争用是有意的数据争用,其存在不会影响程序的正确性。

In addition to benign data-races, a large class of applications allow data-races because they rely on lock-free and wait-free algorithms which are difficult to design correctly. The Thread Analyzer can help determine the locations of data-races in these applications.
除了良性数据竞争之外,一大类应用程序还允许数据竞争,因为它们依赖于难以正确设计的无锁和无等待算法。线程分析器可以帮助确定这些应用程序中数据争用的位置。

也就是说,Data Race 并不定义是有害的,通过如无锁、无等待算法等机制采用良性数据竞争反而更对程序有利。但是,这对开发者而言仍然是艰巨的挑战。所以 Go 提供 Data Race 检测的功能,帮助开发者分析并发代码。

本文示例代码见这里.

参考文档


相关阅读