大家好,我是极客老墨。

写并发代码最怕什么?不是死锁,不是性能问题,而是那些偶尔才出现、难以复现的诡异 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:检测到竞争后的退出码,默认是 66
  • strip_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 倍
  • 每个 deferrecover 会额外分配 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 让它变得容易发现。记住这几点:

  1. 开发和测试时始终使用 -race 参数
  2. 共享变量要么加锁,要么使用 atomic
  3. map 和 slice 不是并发安全的
  4. 循环变量要小心捕获
  5. 不要在生产环境开启 Race Detector

本文的示例代码在这里,可以自己跑跑看。

你在项目中遇到过哪些诡异的 Data Race 问题?欢迎评论区分享!

极客老墨,继续折腾!

参考资料:


相关阅读