大家好,我是极客老墨。
写并发代码最怕什么?不是死锁,不是性能问题,而是那些偶尔才出现、难以复现的诡异 bug。这些 bug 的罪魁祸首往往就是 Data Race(数据竞争)。
什么是 Data Race?
Data Race 的定义很简单:当两个或多个 goroutine 同时访问同一个变量,且至少有一个在写入时,就会发生数据竞争。
这就像两个人同时在一张纸上写字,最后的结果可能是谁的字都不完整,或者干脆就是一团乱码。
举个例子:
1var counter int
2
3func increment() {
4 counter++ // 看起来是一行代码,实际上是三个操作:读取、加1、写入
5}
6
7func main() {
8 go increment()
9 go increment()
10 time.Sleep(time.Second)
11 fmt.Println(counter) // 结果可能是 1,也可能是 2
12}
这段代码看起来没问题,但 counter++ 并不是原子操作。两个 goroutine 可能同时读到 0,然后都写入 1,最终结果就是 1 而不是期望的 2。
顺便说一句,Data Race 和 Race Condition 是两个不同的概念,很多人会混淆。我之前翻译过一篇文章专门讨论这个问题,感兴趣可以看看。
Go 内存模型定义了 Happens-Before 规则来保证程序执行的顺序性,这和 Java 内存模型的思路类似。但光有规则还不够,Go 从 1.1 版本开始提供了 Race Detector,让我们能在运行时检测出这些问题。
如何检测 Data Race?
Go 提供了一个超级简单的方法:在编译或运行时加上 -race 参数就行了。
1$ go test -race mypkg # 测试时检测
2$ go run -race mysrc.go # 运行时检测
3$ go build -race mycmd # 编译时启用检测
4$ go install -race mypkg # 安装时启用检测
来看一个实际的例子:
1func main() {
2 c := make(chan int)
3 m := make(map[string]int)
4
5 go func() {
6 m["a"] = 1 // goroutine 1 写入 map
7 c <- 1
8 }()
9
10 m["a"] = 2 // main goroutine 写入 map
11 <-c
12
13 for k, v := range m {
14 fmt.Printf("key = %v, val = %v\n", k, v)
15 }
16}
这段代码有明显的问题:两个 goroutine 同时写 map,但平时运行可能看不出来。加上 -race 参数运行:
1$ go run -race race_demo.go
2==================
3WARNING: DATA RACE
4Write at 0x00c00007e180 by goroutine 6:
5 runtime.mapassign_faststr()
6 /Users/hank/software/go1.20.5/src/runtime/map_faststr.go:203 +0x0
7 main.main.func1()
8 /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/race_demo.go:10 +0x50
9
10Previous write at 0x00c00007e180 by main goroutine:
11 runtime.mapassign_faststr()
12 /Users/hank/software/go1.20.5/src/runtime/map_faststr.go:203 +0x0
13 main.main()
14 /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/race_demo.go:13 +0x13a
15
16Goroutine 6 (running) created at:
17 main.main()
18 /Users/hank/workspace/mine/go-projects/go-learning/02-advanced/goroutine/datarace/race_demo.go:9 +0x11d
19==================
20key = a, val = 1
21Found 2 data race(s)
22exit status 66
Race Detector 会告诉你:
- 哪两个 goroutine 发生了冲突
- 冲突发生在哪一行代码
- 具体的调用栈信息
有了这些信息,定位问题就容易多了。
配置选项
Race Detector 可以通过 GORACE 环境变量来配置:
log_path:指定报告输出位置,默认是stderr。可以设置为文件路径,会生成log_path.pid格式的文件exitcode:检测到竞争后的退出码,默认是 66strip_path_prefix:从报告中去掉路径前缀,让输出更简洁history_size:每个 goroutine 的内存访问历史大小,默认是 1。增大这个值可以避免"failed to restore the stack"错误,但会增加内存使用halt_on_error:是否在第一次检测到竞争后立即退出,默认是 0(不退出)atexit_sleep_ms:主 goroutine 退出前的等待时间(毫秒),默认 1000
使用示例:
1GORACE="log_path=./race_log strip_path_prefix=/Users/hank" go run -race race_demo.go
这会把报告写到 race_log.xxx 文件中,并且去掉路径中的 /Users/hank 前缀。
常见的 Data Race 场景
1. 循环变量捕获
这是最经典的坑,示例代码:
1func race() {
2 var wg sync.WaitGroup
3 wg.Add(5)
4 for i := 0; i < 5; i++ {
5 go func() {
6 fmt.Print(i) // 所有 goroutine 都读取同一个 i
7 wg.Done()
8 }()
9 }
10 wg.Wait()
11}
在 Go 1.21 之前,这段代码会输出 55555,因为所有 goroutine 启动时,循环已经结束,i 的值都是 5。Go 1.22 之后修复了这个问题,每次循环会创建新的变量。
但如果你用的是老版本,或者想兼容老代码,正确的写法是:
1func fixRace() {
2 var wg sync.WaitGroup
3 wg.Add(5)
4 for i := 0; i < 5; i++ {
5 go func(j int) {
6 fmt.Print(j) // 使用参数传递,每个 goroutine 有自己的副本
7 wg.Done()
8 }(i)
9 }
10 wg.Wait()
11}
2. 意外共享的变量
1func ParallelWrite(data []byte) chan error {
2 res := make(chan error, 2)
3 f1, err := os.Create("file1")
4 if err != nil {
5 res <- err
6 } else {
7 go func() {
8 _, err = f1.Write(data) // 这里的 err 和外层的 err 是同一个变量
9 res <- err
10 f1.Close()
11 }()
12 }
13 f2, err := os.Create("file2") // 又一次写入 err
14 if err != nil {
15 res <- err
16 } else {
17 go func() {
18 _, err = f2.Write(data) // 又一次写入 err
19 res <- err
20 f2.Close()
21 }()
22 }
23 return res
24}
这个问题很隐蔽,err 变量被多个 goroutine 共享了。解决方法是使用 := 创建新变量:
1_, err := f1.Write(data) // 创建新的局部变量
3. 未保护的全局变量
1var service = make(map[string]net.Addr)
2
3func RegisterService(name string, addr net.Addr) {
4 service[name] = addr // 没有锁保护
5}
6
7func LookupService(name string) net.Addr {
8 return service[name] // 没有锁保护
9}
map 不是并发安全的,多个 goroutine 同时读写会 panic。解决方案有两个:
方案一:使用 sync.Mutex
1var (
2 service = make(map[string]net.Addr)
3 mu sync.RWMutex
4)
5
6func RegisterService(name string, addr net.Addr) {
7 mu.Lock()
8 defer mu.Unlock()
9 service[name] = addr
10}
11
12func LookupService(name string) net.Addr {
13 mu.RLock()
14 defer mu.RUnlock()
15 return service[name]
16}
方案二:使用 sync.Map
1var service sync.Map
2
3func RegisterService(name string, addr net.Addr) {
4 service.Store(name, addr)
5}
6
7func LookupService(name string) net.Addr {
8 v, _ := service.Load(name)
9 return v.(net.Addr)
10}
4. 原始类型的并发访问
1type Watchdog struct{ last int64 }
2
3func (w *Watchdog) KeepAlive() {
4 w.last = time.Now().UnixNano() // 写入
5}
6
7func (w *Watchdog) Start() {
8 go func() {
9 for {
10 time.Sleep(time.Second)
11 if w.last < time.Now().Add(-10*time.Second).UnixNano() { // 读取
12 fmt.Println("No keepalives for 10 seconds. Dying.")
13 os.Exit(1)
14 }
15 }
16 }()
17}
即使是 int64 这样的原始类型,并发读写也不安全。解决方法是使用 sync/atomic:
1type Watchdog struct{ last atomic.Int64 }
2
3func (w *Watchdog) KeepAlive() {
4 w.last.Store(time.Now().UnixNano())
5}
6
7func (w *Watchdog) Start() {
8 go func() {
9 for {
10 time.Sleep(time.Second)
11 if w.last.Load() < time.Now().Add(-10*time.Second).UnixNano() {
12 fmt.Println("No keepalives for 10 seconds. Dying.")
13 os.Exit(1)
14 }
15 }
16 }()
17}
5. channel 的发送和关闭
1c := make(chan struct{})
2
3go func() { c <- struct{}{} }()
4close(c) // 可能在发送的同时关闭 channel
这会导致 panic。正确的做法是先接收,再关闭:
1c := make(chan struct{})
2
3go func() { c <- struct{}{} }()
4<-c // 等待发送完成
5close(c) // 安全关闭
检测的成本
Race Detector 不是免费的午餐,它会带来一些开销:
- 内存使用增加 5-10 倍
- 执行时间增加 2-20 倍
- 每个
defer和recover会额外分配 8 字节
所以,Race Detector 只适合在开发和测试环境使用,不要在生产环境开启。
另外,Race Detector 需要 cgo 支持,在非 Darwin 系统上还需要 C 编译器。目前支持的平台包括:
- linux/amd64、linux/ppc64le、linux/arm64、linux/s390x
- freebsd/amd64、netbsd/amd64
- darwin/amd64、darwin/arm64
- windows/amd64
Windows 上需要 mingw-w64 8.0 或更高版本。
关于 Benign Data Race
有一个有争议的话题:Benign Data Race(良性数据竞争)。
有些人认为,某些情况下的数据竞争是"无害"的,甚至能提升性能。比如 Java 文档就提到:
一些多线程应用程序故意允许数据竞争以获得更好的性能。良性数据竞争是有意的数据竞争,其存在不会影响程序的正确性。
但我的建议是:除非你真的知道自己在做什么,否则不要尝试"良性数据竞争"。Go 的 Race Detector 会把所有数据竞争都标记出来,这是有原因的。
如果你确实需要无锁算法,可以使用 sync/atomic 包,它提供了原子操作,既安全又高效。
总结
Data Race 是并发编程中最难调试的问题之一,但 Go 的 Race Detector 让它变得容易发现。记住这几点:
- 开发和测试时始终使用
-race参数 - 共享变量要么加锁,要么使用 atomic
- map 和 slice 不是并发安全的
- 循环变量要小心捕获
- 不要在生产环境开启 Race Detector
本文的示例代码在这里,可以自己跑跑看。
你在项目中遇到过哪些诡异的 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