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 |
三条最容易踩空的推论:
- goroutine 退出不建立任何边:
go func() { result = 42 }()之后直接读result仍是竞争——必须用 channel /WaitGroup/atomic把结果送回来。 time.Sleep不建立边:它只推迟墙上时间,不产生可见性保证。- 同包多文件的初始化顺序通常按文件名字典序,但那只是构建系统被「鼓励」的做法(语言规范并未保证),不能当同步手段用。
⚠️ 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 运行时,做三件事:
- 影子内存(shadow memory):为每个内存字维护「最近访问它的 epoch / 读写标记 / 锁集合」,粒度通常 8 字节 4 个状态;
- 向量时钟(epoch):每个 goroutine 维护逻辑时间;同步操作(
go、channel、mutex、WaitGroup、atomic、Once、sync.Cond)会建立 happens-before 边并合并时钟; - 冲突检查:每次访问内存时,用当前 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
==================
四个信息块:
- 本次访问:哪个 goroutine、哪一行、读还是写;
- 上一次冲突访问:谁、哪一行、读还是写;
- 两个 goroutine 的创建栈:反推它们的「出生地」,通常能看出是谁把共享变量漏出去了;
- 地址:同一个地址在两次报告里重复出现,说明是同一个变量。
常用开关:
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 乐观并发与悲观并发。