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 的文件。特殊值stdout
和stderr
会将报告分别写入标准输出和标准错误。 - 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发生在原始类型的变量上(bool
、int
、int64
等),这些问题是由内存访问的非原子性、编译器优化的干扰或访问处理器内存的重新排序问题引起的。解决办法是使用 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 检测的功能,帮助开发者分析并发代码。
本文示例代码见这里.
参考文档
- https://go.dev/doc/articles/race_detector
- https://go.dev/ref/mem
- https://shanehowearth.com/benign-data-races-in-go/
- https://docs.oracle.com/cd/E19205-01/820-0619/gecqt/index.html