《Go 语言编程入门》11.3 用 -race 发现竞态

前面两节都说「用 -race 验证」,本节把 -race 当成正式工具讲透:它是编译期插桩而非采样,能给出带行号的读写双方栈;逐行解读一份真实竞态报告,实测它带来的性能开销,说明它查不出哪些问题、该怎么写并发测试,以及如何固定进 CI 流程。

11.3 用 -race 发现竞态

11.1 节和 11.2 节里,「用 -race 验证」出现了很多次,但一直没解释它到底是什么。这一节补齐。

先说结论:-race 是 Go 工具链里性价比最高的一个开关。它不需要你改一行代码,只要在命令上加一个 flag,就能把「只在特定时序下出现、测试环境永远复现不了」的数据竞态变成一份带文件名和行号的报告。并发代码没有 -race 跑过,就不算验证过。

本节把 TaskAPI 推进到:为并发安全 store 补上一组能在 -race 下通过的并发测试,并把这套验证方式固定进开发流程。

11.3.1 race detector 是什么

它不是采样分析器,而是编译期插桩 + 运行时检测:

  1. 加 -race 编译时,编译器会在每一次内存读写、每一次同步操作(锁、channel、atomic)前后插入检测代码。
  2. 运行时,检测器维护每个内存位置最近的访问记录和一组「happens-before」关系。
  3. 当它发现「两个 goroutine 访问同一地址、至少一个是写、且两者之间没有任何同步关系」时,立刻打印报告。

关键点是第 3 条里的 happens-before。它检查的不是「有没有加锁」这个表面形式,而是「这两次访问之间有没有建立同步顺序」。所以下面这些都算建立了 happens-before:

  • 对同一把 Mutex 的 Unlock 早于后续的 Lock;
  • 向 channel 的发送早于对应的接收;
  • wg.Done() 早于 Wait() 返回;
  • 对同一变量的 atomic.Store 早于后续的 atomic.Load。

只要建立了顺序,检测器就认为安全。反之,哪怕代码看起来「碰巧不会冲突」,它也会报。

11.3.2 三种用法

# 直接跑
GOTOOLCHAIN=go1.27.0 go run -race .

# 编译出一个带检测的二进制
GOTOOLCHAIN=go1.27.0 go build -race -o taskapi-race .

# 跑测试(最常用)
GOTOOLCHAIN=go1.27.0 go test -race ./...

go test -race 是日常主力:测试里本来就有并发场景,加上 -race 就等于每次跑测试都顺带做一次并发体检。

两个运行时行为需要知道:

  • 检测到竞态时,进程以退出码 66 结束(不是 1)。CI 里如果只判断「非 0 即失败」没问题,但如果有脚本精确匹配退出码,要记得这个数字。
  • 环境变量 GORACE 可以调整行为,例如 GORACE="halt_on_error=1" 让检测到第一个竞态就立刻终止(默认会继续跑完并汇总所有竞态)。

11.3.3 逐行读一份真实报告

下面这段程序有一个典型的竞态——count++ 没有同步:

package main

import (
	"fmt"
	"sync"
)

func main() {
	count := 0
	var wg sync.WaitGroup
	for i := 0; i < 4; i++ {
		wg.Go(func() {
			count++
		})
	}
	wg.Wait()
	fmt.Println("count =", count)
}

GOTOOLCHAIN=go1.27.0 go run -race . 的实测输出:

==================
WARNING: DATA RACE
Read at 0x00c000012118 by goroutine 8:
  main.main.func1()
      main.go:13 +0x2c

Previous write at 0x00c000012118 by goroutine 7:
  main.main.func1()
      main.go:13 +0x3c

Goroutine 8 (running) created at:
  sync.(*WaitGroup).Go()

Goroutine 7 (finished) created at:
  sync.(*WaitGroup).Go()
==================
count = 4
Found 2 data race(s)
exit status 66

逐段解读:

片段含义
WARNING: DATA RACE一对冲突访问,一个报告块 = 一次冲突
Read at 0x...118 by goroutine 8冲突的一方:读,地址,goroutine 编号
main.go:13直接指到出问题的源码行,这是报告最有价值的部分
Previous write at ... by goroutine 7冲突的另一方:写,以及是谁写的
created at: sync.(*WaitGroup).Go()两个 goroutine 各自的出生点
Found 2 data race(s)一共发现 2 对冲突
exit status 66检测到竞态的固定退出码

注意最后一行 count = 4:这次运行的结果恰好是对的。四个 goroutine 各加一次,count 是 4,看起来毫无问题。但检测器依然报了 2 对冲突——因为它看的是 happens-before 关系,而不是结果对不对。

这正是数据竞态最危险的地方:没有 -race,你根本无法判断「结果对」是因为代码正确,还是因为运气好。

11.3.4 修复它

两种修法,取决于语义。

方案一:用 atomic(单个计数器,最轻):

var count atomic.Int64
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
	wg.Go(func() { count.Add(1) })
}
wg.Wait()
fmt.Println("count =", count.Load())

实测输出(go run -race,无任何报告):

count = 4

方案二:用 Mutex(如果 count 只是临界区里的一小部分逻辑)。选择依据在 11.1.7 的选型表里:单个变量用 atomic,多变量一致性用锁。

修完之后,一定要用 -race 再跑一遍确认报告消失。有时候修复引入的锁本身用错了(比如复制了 Mutex),-race 同样能抓出来。

11.3.5 代价:-race 有多慢

检测器要记录每次访问和同步事件,开销不可能为零。实测两个点:

场景无 -race有 -race倍数
紧循环并发读 map3.48 ns/op416.7 ns/op约 120 倍
4 worker 做小段计算3082 ns/op8215 ns/op约 2.7 倍

(Apple M1 Pro,go1.27.0)

第一个数字很吓人,但它对应的是「几乎不干实事、纯并发访问内存」的极端微基准——这种负载下检测器的相对开销最大。第二个更接近真实程序:大约 2~3 倍,业务逻辑越重、内存访问越稀,倍数越低。

另外检测器本身要占内存(每个内存位置都要记录历史),大程序上可能多占几倍到十几倍的堆。

实践建议:

  • 开发时:go test -race ./... 当默认动作,慢一点无所谓。
  • 压测/性能验证时:关掉 -race,否则测出来的数字没有参考价值。
  • 生产环境:不要开。它不是为线上设计的。

11.3.6 盲区:-race 查不出什么

-race 很强,但不是万能。它只能发现「在本次运行中实际发生」的竞态。下面这些情况它抓不到:

情况为什么抓不到
有竞态但这次没触发检测器只看到实际执行的访问
测试用例没覆盖并发路径没跑到的代码不会被检查
死锁死锁是「卡住」不是「冲突」,要用超时或栈 dump 排查
逻辑错误(非竞态)比如锁的顺序不一致导致的数据不一致
用了 unsafe / sync/atomic 绕过检测器信任 atomic 建立的顺序

所以正确的心态是:-race 报告 = 有竞态(确定);-race 无报告 ≠ 无竞态(只是这次没出现)。

要提高覆盖率,办法是让测试主动制造并发:多个 goroutine 同时读写、循环多次、用 -count 重复跑:

GOTOOLCHAIN=go1.27.0 go test -race -count=10 -run TestMemStore ./...

-count=10 会让每个测试重复执行 10 次,用重复次数去逼近那些「偶发时序」。

11.3.7 把 -race 固定进工作流

三个落点,按优先级排:

  1. 本地提交前:把 go test -race ./... 写进 Makefile 或 go generate 脚本里,别靠记性。
  2. CI 流水线:单独一个 job 跑 go test -race ./...,与普通测试分开,这样它的耗时不影响主流水线。
  3. 代码评审清单:任何涉及 go、chan、sync、atomic 的 PR,评审时必须确认作者跑过 -race。

TaskAPI 后续每次新增并发组件,都要同步补一个能在 -race 下通过的测试——这是第 8 章表驱动测试思想在并发场景的延续。

11.3.8 TaskAPI 的并发测试

给 11.1 节的 MemStore 补一个并发测试:

package store

import (
	"fmt"
	"sync"
	"testing"
)

func TestMemStoreConcurrent(t *testing.T) {
	s := NewMemStore()
	var wg sync.WaitGroup
	for i := int64(1); i <= 200; i++ {
		wg.Go(func() {
			s.Save(Task{ID: i, Title: fmt.Sprintf("任务%d", i)})
			if _, ok := s.Get(i); !ok {
				t.Errorf("任务 %d 写入后读不到", i)
			}
		})
	}
	wg.Wait()
}

跑法:

GOTOOLCHAIN=go1.27.0 go test -race -v ./...

实测输出:

=== RUN   TestMemStoreConcurrent
--- PASS: TestMemStoreConcurrent (0.00s)
PASS
ok  	taskapi	1.492s

把这个测试里 store 的锁去掉,同样的命令立刻变成:

WARNING: DATA RACE
Write at 0x00c0001206c0 by goroutine 9:
  runtime.mapaccess2_fast64()
  taskapi.(*MemStore).Save()
      store.go:14 +0xa4
  taskapi.TestMemStoreConcurrent.func1()
      store_test.go:14 +0x38

Previous write at 0x00c0001206c0 by goroutine 10:
  ...
fatal error: concurrent map writes
FAIL	taskapi	0.685s

这里同时出现了竞态报告和 fatal error:map 的并发写保护先触发了致命错误。这也说明同一份代码可能以不同方式暴露问题——有时是 -race 报告,有时是直接崩溃。

一个写法上的提醒:测试里的 goroutine 不要调用 t.Fatalf(它只能在测试主 goroutine 里调用),用 t.Errorf 记录失败即可;-race 的报告是独立于 t 的输出,不受影响。

11.3.9 小结与练习

  1. -race 是编译期插桩的 happens-before 检测器,不是采样器。
  2. 报告里的 main.go:13 是定位问题的关键,exit status 66 是它的固定退出码。
  3. 结果正确不代表没有竞态;只有 -race 能给出判断依据。
  4. 开销约 2~3 倍(真实负载),紧循环微基准可达百倍;生产环境不要开。
  5. -race 无报告不等于无竞态,用 -count 提高重复次数、主动制造并发来提升覆盖率。

练习:

  • 把 11.3.3 的程序里 count++ 改成 count += 1、count = count + 1,观察 -race 是否都能抓到(提示:都能,因为本质都是读改写)。
  • 用 GORACE="halt_on_error=1" 重跑,观察输出与默认行为有何不同。
  • 给自己写过的任意一段并发代码加上 -race -count=10,看能不能跑出报告。
  • 思考:为什么 wg.Done() 与 Wait() 之间能建立 happens-before,而两个 wg.Go 里的 count++ 之间不能?

下一章我们进入 context:让取消信号沿着调用链传播,让 TaskAPI 能在 Ctrl-C 之后优雅退出。

阅读导航:上一节:11.2 WaitGroup/Once/sync.Map · 下一节:12.1 context 的取消与传播 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练