15.7 竞态检测与数据竞争

一句话

数据竞争是「两个 goroutine 访问同一内存、至少一个是写、且没有 happens-before 关系」;Go 的 race detector 用动态 happens-before 分析证明「有竞争」,但永远无法证明「没有」。

定义:四个条件,缺一不可

条件 说明 反例(不算数据竞争)
两个及以上执行单元 goroutine / 线程 单 goroutine 顺序访问
访问同一内存位置 同一个变量/字段/数组元素 各写各的局部变量
至少一次是写 读读不构成竞争 多个 goroutine 只读共享配置
没有 happens-before 无锁、无 channel、无 atomic、无 WaitGroup 等同步 mutex 保护下的读写

Go 内存模型保证的是无竞争程序的顺序一致(DRF-SC);有竞争的程序不像 C/C++ 那样「完全未定义」——单字读仍必须读到某个真实写入过的值——但结果不可依赖:实现可以直接检测并终止程序,多字结构(interface、map、slice)可能读到指针与长度不匹配的中间态,甚至内存损坏。一句话:不是「读到旧值」这么温和,而是「程序已经错了」。

数据竞争 ≠ 逻辑竞态

类型 例子 加锁能解决吗
数据竞争 两个 goroutine 同时 counter++ 能(加锁/原子)
原子性破坏 先查余额再扣款,两步之间被插入 不能,要扩大临界区或换操作
顺序破坏 初始化未完成就发布对象指针 部分能,要靠发布协议(once/atomic)

什么建立 happens-before

Go 内存模型只承诺一件事:无数据竞争的程序,执行结果等价于某种顺序一致的执行(DRF-SC)。「无竞争」的判据就是 happens-before 边——两条访问同一内存的操作之间要么有边,要么就是竞争。边的来源只有下面这些(按 Go 2022 年修订版内存模型的表述):

来源 保证
同一 goroutine 内的语句 按程序顺序;编译器可重排,但不能改变单线程可观察的结果
go 语句 go f() 本身 happens before f 开始执行;f 的退出不保证任何事
channel 第 n 次发送 happens before 第 n 次接收完成;close(ch) happens before 收到零值;无缓冲时接收 happens before 发送完成;容量 m 时第 n 次接收 happens before 第 n+m 次发送完成
Mutex / RWMutex 第 n 次 Unlock happens before 第 n+1 次 Lock 返回;Unlock happens before 其后的 RLock 返回;RUnlock happens before 其后的 Lock
WaitGroup 使计数归零的那次 Done happens before Wait 返回
Once f 的那一次执行 happens before 任何 Do 返回
包初始化 被 import 的包先初始化,main 在所有 init 之后
atomic(Go 1.19 起) 原子操作表现为某个顺序一致顺序;观察到 A 的效果的 B 满足 A happens before B

三条最容易踩空的推论:

  1. goroutine 退出不建立任何边:go func() { result = 42 }() 之后直接读 result 仍是竞争——必须用 channel / WaitGroup / atomic 把结果送回来。
  2. time.Sleep 不建立边:它只推迟墙上时间,不产生可见性保证。
  3. 同包多文件的初始化顺序通常按文件名字典序,但那只是构建系统被「鼓励」的做法(语言规范并未保证),不能当同步手段用。

⚠️ 2020 年前后的资料常说「atomic 没有规范层面的顺序保证,别用它做多变量发布」,这条在 Go 1.19 修订内存模型后已经改变:原子操作现在有正式的 synchronizes-before 关系。但原子变量仍只能全部用原子操作访问——混入普通读写依旧是数据竞争,atomic 也依然不能保护多字段之间的不变量(见 15.6 并发原语 (channel / context / sync))。

它到底会怎么坏

同一段「1000 个 goroutine 各 counter++ 1000 次」的程序,本机实测(Go 1.27,macOS,Apple M1):

package main

import (
	"fmt"
	"sync"
)

func main() {
	var counter int
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			for j := 0; j < 1000; j++ {
				counter++ // 读-改-写三步,无同步
			}
		}()
	}
	wg.Wait()
	fmt.Println("counter =", counter, "期望 1000000")
}
counter = 154782 期望 1000000

丢了 84.5% 的更新。同一个程序用 -race 跑,除了丢得少一点(counter = 454317,因为检测器拖慢了执行、改变了交错),还会打印两条 WARNING: DATA RACE,并以退出码 66 结束。

症状 机制 你看到的
丢失更新 i++ 是读-改-写三步,交错执行互相覆盖 计数偏小、库存少扣/多扣
撕裂读 非原子读写跨缓存行/字长 读到「半新半旧」的结构体
永不退出 无同步的忙等:写入不保证被观察到,循环不保证结束 for !done {} 死循环,加 -race 可能不复现
部分初始化 指针先发布,字段后写入 读到零值字段、nil map、空切片
运行时崩溃 map 并发写被 runtime 主动检测 fatal error: concurrent map writes
数据错乱 slice 扩容时读到旧底层数组 读到已失效的元素、长度与容量不一致

map 的例子(本机实测输出):

fatal error: concurrent map writes

goroutine 41 [running]:
internal/runtime/maps.fatal({0x100ecf11c?, 0x0?})
	/opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/panic.go:1195 +0x20
main.main.func1(0x7)
	/tmp/luban-exp/maprace/main.go:16 +0x60

这类崩溃不可 recover(fatal error 不是 panic),整个进程退出。

race detector 怎么工作

go build -race / go test -race 会链接 ThreadSanitizer 运行时,做三件事:

  1. 影子内存(shadow memory):为每个内存字维护「最近访问它的 epoch / 读写标记 / 锁集合」,粒度通常 8 字节 4 个状态;
  2. 向量时钟(epoch):每个 goroutine 维护逻辑时间;同步操作(go、channel、mutex、WaitGroup、atomic、Once、sync.Cond)会建立 happens-before 边并合并时钟;
  3. 冲突检查:每次访问内存时,用当前 goroutine 的时钟与影子内存里记录的时钟比较——不可比较(unordered)且至少一个是写,就报 race。

关键推论:检测与「实际是否同时发生」无关,只与「有没有 happens-before 关系」有关。两次访问在墙上时间完全错开,只要没有同步建立先后,TSan 一样会报——所以「我加了 sleep 就好了」是错觉,sleep 不建立 happens-before。

⚠️ 影子内存只保留有限的历史(GORACE=history_size=… 可调,越大越占内存),旧访问会被覆盖。长跑程序里,早先的写可能已经被遗忘,检测不到后来的读。

开销:实测,不是「大概 10 倍」

本机微基准(Go 1.27,Apple M1,8 核;go test -bench . -benchtime=500ms -count=3):

基准 无 -race 有 -race 放大
sync.Mutex 自增(并行) 83.7 / 88.0 / 83.4 ns/op 446.7 / 437.4 / 443.5 ns/op ≈ 5.3×
atomic.AddInt64(并行) 40.9 / 42.3 / 40.3 ns/op 291.7 / 282.1 / 301.7 ns/op ≈ 7.1×

内存与指令(同一个程序,/usr/bin/time -l 采集):

指标 无 -race 有 -race 放大
峰值内存 33.4 MB 106.1 MB 3.2×
退休指令数 4.22 亿 6.86 亿 1.6×

同步越密集,放大越明显——上面的基准是「每次操作都要同步」的最坏情况;真实业务里同步密度低,放大会小一些,但几倍的 CPU 与内存开销足以让它只能待在测试环境。

五种「没报」不等于「没有」

漏报场景 原因 例子
代码没跑到 检测器只分析实际执行的指令 错误分支、灰度开关打开才走的路径、rare 输入的竞态
cgo / 汇编访问 不插桩,TSan 看不到 C 代码回调里读写 Go 内存、//go:linkname
unsafe / mmap / 共享内存 绕过 Go 的内存模型 unsafe.Pointer 手工构造、mmap 文件、与别的进程共享页
历史被覆盖 影子内存只保留最近若干 epoch 长跑程序,早期写 + 很久之后的读
非内存资源的竞争 检测器只管内存 文件锁、数据库连接、外部设备、端口

所以正确的心态是:-race 报出来的必须修;没报的不代表安全,要靠设计(不共享)与代码评审兜底。

怎么读报告

==================
WARNING: DATA RACE
Read at 0x00c00011e038 by goroutine 8:
  main.main.func1()
      /tmp/luban-exp/race/main.go:17 +0x88

Previous write at 0x00c00011e038 by goroutine 7:
  main.main.func1()
      /tmp/luban-exp/race/main.go:17 +0x98

Goroutine 8 (running) created at:
  main.main()
      /tmp/luban-exp/race/main.go:14 +0x70

Goroutine 7 (finished) created at:
  main.main()
      /tmp/luban-exp/race/main.go:14 +0x70
==================

四个信息块:

  1. 本次访问:哪个 goroutine、哪一行、读还是写;
  2. 上一次冲突访问:谁、哪一行、读还是写;
  3. 两个 goroutine 的创建栈:反推它们的「出生地」,通常能看出是谁把共享变量漏出去了;
  4. 地址:同一个地址在两次报告里重复出现,说明是同一个变量。

常用开关:

go test -race ./...                                   # 全量测试开竞态检测
go test -race -count=10 -run 'TestConcurrent' ./pkg   # 重复跑,提高路径覆盖
GORACE="halt_on_error=1 history_size=7" go test -race ./...   # 首次命中即退出,加大历史
GORACE="log_path=race.log" go test -race ./...        # 每个进程写一份报告

⚠️ -race 需要 cgo 工具链。CGO_ENABLED=0 的静态构建、部分交叉编译环境里开不了;CI 镜像里要显式装 gcc/clang。

修法:从「加锁」退回到「不共享」

优先级从高到低:

手段 适用 代价/注意
不共享(每 goroutine 私有副本) 可并行的计算、统计聚合 需要 merge 步骤
消息传递(channel 转移所有权) 状态机、队列、请求处理 通道本身有开销,注意缓冲与背压
原子操作(atomic) 单个计数器、标志位、指针发布 只能保护单变量的单次操作,不能保护复合逻辑
互斥锁(Mutex/RWMutex) 复合不变式、多字段一致 临界区要小;注意锁顺序防死锁
分片锁(sharded mutex) 高并发计数/缓存 实现复杂,内存换并发(实测见 34.4 优化手段)

三种修法都能得到正确结果(本机 go run -race 实测输出):

mutex   : 1000000
atomic  : 1000000
channel : 1000000

对应的关键代码:

// 1) 互斥锁:保护复合操作
mu.Lock()
n++
mu.Unlock()

// 2) 原子:只适用于单变量的读改写
atomic.AddInt64(&n, 1)     // 不要写 n = atomic.LoadInt64(&n) + 1

// 3) channel:把所有权交给一个 goroutine,别人只发消息
ch <- 1                    // 由唯一的消费方维护计数

两个最容易踩的坑:

  • atomic 不等于线程安全:if atomic.LoadInt32(&flag) == 0 { counter++ } 里的 counter++ 仍然是竞争。原子操作保护的是它自己那一次读/写,不是代码块。
  • 拷贝带锁的结构体:mu sync.Mutex 被值拷贝后,两个副本锁的是不同的锁。go vet 的 copylocks 检查能抓这一类,务必开。

工具链与 CI

层次 做什么 命令/配置
编译期 检查锁拷贝、循环变量捕获等 go vet ./...、staticcheck ./...
单元测试 所有并发相关包强制 -race go test -race ./...
压力测试 重复跑 + 提高并发,覆盖罕见交错 -count=100、-cpu=1,2,8、限制 GOMAXPROCS
集成/端到端 对并发路径加长跑 定时任务 nightly,-race + 真实依赖
生产 不要常驻 -race 5–10 倍开销;只在 canary 短时间开

-race 与 GOMAXPROCS 的组合值得单独说:很多竞争只在多核并行时暴露,把 GOMAXPROCS=1 跑绿了不代表没问题;反过来,有些竞争在单核下才会出现(调度点不同)。CI 里两者都跑一遍最稳。

与测试章节的分工

本节讲「竞争是什么、检测器怎么工作、怎么修」;「怎么在流水线里稳定复现、并发测试怎么写」属于测试工程,见 53.9 竞态检测与并发测试。两者不要互相抄:这里的代码是原理演示,那边的重点是压测编排与门禁。

反模式

反模式 后果
用 atomic 包住复合操作就以为线程安全 竞争仍在,只是更难发现
值拷贝带 sync.Mutex 的结构体 两个副本各锁各的,保护完全失效
Double-checked locking 不用 atomic 读到部分初始化对象,字段是零值
用 time.Sleep 调时序「让它稳定」 sleep 不建立 happens-before,竞争还在,只是没撞上
跑一次 -race 绿了就认为干净 未执行路径、cgo、unsafe 都会漏
用 sync.Map 替代数据结构设计 只适合读多写少、键集合稳定的场景;滥用后一致性更难推理
把 -race 二进制发到生产 CPU/内存成倍上涨,延迟抖动
锁粒度过大(一把大锁保护所有状态) 并发退化成串行,吞吐上不去
只加锁不改共享设计 死锁风险随锁数量上升,扩展性变差

自检

Q1 counter++ 用 atomic.AddInt64 就安全了,那 if atomic.LoadInt32(&ready) == 1 { data.Write() } 安全吗?

▸ 答案:不一定。原子操作只保证那一次读或写不可分割;if 判断之后的复合操作(data.Write(),或 counter = counter + 1 这种读-改-写)仍可能与其他 goroutine 竞争。要么把整段放进锁/单一 owner,要么把状态设计成「一次原子交换就能决定」的形式(CAS 循环)。

Q2 -race 没报,就能说明没有数据竞争吗?举两种漏报场景。

▸ 答案:不能。race detector 只能分析实际执行到的代码:① 竞态在错误分支或灰度开关里,测试没走到;② 竞态发生在 cgo/汇编/unsafe 访问的内存上,检测器没有插桩。此外影子内存只保留有限历史,长跑程序的早期访问可能被遗忘。正确姿态是「报了必须修,没报靠设计与评审兜底」。

Q3 为什么 race detector 不能常驻生产?

▸ 答案:它给每次内存访问加检测逻辑、维护影子内存与向量时钟:本机微基准里 mutex 场景慢 5.3 倍、atomic 场景慢 7.1 倍,峰值内存 3.2 倍。生产环境的延迟与成本都无法接受;而且它只能发现问题、不能修复,报错即退出(默认)还会造成可用性事故。合理做法是 CI 全量开、canary 短期开。

Q4 一个 10 万 QPS 的计数器,用 mutex 和 atomic 差多少?为什么分片锁还能更快?

▸ 答案:本机并行自增基准里,atomic 约 40 ns/op、mutex 约 84 ns/op(无 -race),atomic 快一倍多;但两者都在同一个缓存行上争用,核数越多越糟(cache line ping-pong)。分片锁把计数拆成 N 份、各自落在不同缓存行,最后合并,实测 64 分片比单锁快 9.1 倍(34.4 优化手段)。代价是要处理合并时机与读取的近似性。

Q5 同事说「data race 最多就是读到旧值,加个 time.Sleep 让时序错开就行」。这两句话分别错在哪?

▸ 答案:第一句错在把竞争当成「读到旧值」:Go 只保证无竞争程序的 DRF-SC,有竞争程序的结果不可依赖——可能读到多字结构的中间态、可能被实现检测后终止、可能永远观察不到写入。第二句错在 Sleep 不建立 happens-before,只改变墙上时间的先后,编译器与 CPU 仍可重排,换机器/负载就可能复现。正确替代是显式同步:sync.WaitGroup 等完成、channel 传结果、sync.Once 做初始化、mutex 保护临界区——这些才会在内存模型里建立可见性保证。

Q6 一个 goroutine 把结果写进闭包变量然后退出,主 goroutine 收到「已结束」信号后读它——还需要同步吗?

▸ 答案:需要看「信号」是什么。goroutine 退出本身不建立 happens-before,但如果信号是 wg.Done()(归零后 Wait 返回)或 close(done) 后再接收、或发送一个结果 channel,那么「写结果」在边之前、「读结果」在边之后,是安全的。反过来,用「等待超时后假设它写完了」「轮询一个普通 bool」都不成立。记住边只有三种承载物:channel、锁(含 WaitGroup/Once)、原子操作。

跨篇相关:GMP 调度决定了 goroutine 何时被抢占与切换,见 15.1 GMP 调度模型;channel/context/sync 的语义见 15.6 并发原语 (channel / context / sync);并发模型的全景对比见 15.8 并发编程模型 (线程 / 协程 / Actor / CSP);pprof 与 trace 的用法见 15.5 pprof (CPU / 内存 / 阻塞 / 互斥);分片锁的实测见 34.4 优化手段;流水线里的并发测试与门禁见 53.9 竞态检测与并发测试;分布式场景下的并发控制见 25.1 乐观并发与悲观并发。