一个计数器偶发少算:线上每天错几十次,本地一万次跑一次都不错。加了一个 sync.Mutex 之后错得少了,但没有消失。最后用 go test -race 跑集成测试,两秒就报出竞争——在另一个字段上,跟计数器无关。
数据竞争的特殊性在于:它不是"两个 goroutine 同时写导致值乱",而是语言层面规定程序可以给出任意结果。Go 的内存模型只保证"有 happens-before 关系的操作有确定顺序",没有这层关系的访问,编译器可以重排、CPU 可以缓存、寄存器可以延迟回写——所以同一个程序在不同负载、不同 CPU、不同编译优化下可能给出不同答案。
这也解释了为什么它总在线上才出现:测试通常在单核、低并发、刚启动(缓存冷)的条件下跑。
1. 竞争的三种形态,只有一种是"看起来错"
| 形态 | 代码特征 | 表现 |
|---|---|---|
| 读-改-写 | counter++ |
少算(丢失更新) |
| 发布未同步 | 赋值字段后另一个 goroutine 读 | 读到零值或半初始化对象 |
| 并发访问容器 | 多 goroutine 读写 map |
运行时直接 fatal error |
| 双重检查 | if x == nil { x = new() } |
两个实例、只用一个 |
第一行是唯一"值错了还继续跑"的形态,所以它最容易上线。第二行的危险在于半初始化对象:字段 A 已赋值、字段 B 还是零值,读方拿到一个"看起来存在但内部不一致"的对象——这类 bug 会被误判成业务逻辑问题。
// 三种形态里最容易被忽略的是"发布未同步"
type Config struct { Timeout int; Retries int }
var cfg *Config
func load() { cfg = &Config{Timeout: 3, Retries: 2} } // 写
func get() *Config { return cfg } // 另一个 goroutine 读
// get() 可能返回 nil、可能返回部分初始化的对象——取决于编译器与 CPU
2. happens-before:Go 只给了四条保证
内存模型不谈"原子性",只谈"顺序保证"。Go 给出的建立 happens-before 的手段是有限的:
| 手段 | 建立的关系 | 不保证什么 |
|---|---|---|
| channel 发送 → 对应接收 | 发送前的写对接收方可见 | 其他 channel 之间的顺序 |
sync.Mutex/RWMutex 解锁 → 加锁 |
解锁前的写对下一个持锁者可见 | 无锁读的可见性 |
sync.WaitGroup 的 Done → Wait 返回 |
所有 Done 前的写可见 |
Add 之前发生的事 |
sync.Once 完成 → 所有调用返回 |
初始化内的写可见 | 初始化外的写 |
atomic 操作 |
该变量的原子性与序 | 多个变量的整体一致性 |
第二列的每一行都只对"同一把锁/同一个 channel/同一个 Once"生效。所以"我给写加了锁"并不推出"读是安全的"——读者不加同一把锁,就仍在竞争里。
// 典型错误:写侧加锁、读侧不加
func set(v int) { mu.Lock(); x = v; mu.Unlock() }
func get() int { return x } // ← 竞争:读没有 happens-before 保证
// 修法只有两个:读也加锁,或用 atomic 并把 x 改成 atomic.Int64
3. 复制 sync 类型:最像"玄学"的一类
sync.Mutex 是个结构体。当你把它作为值传递、塞进结构体再复制、或者从函数返回时,你复制出的是两把不同的锁,互斥随之失效。
| 错误写法 | 后果 |
|---|---|
函数参数 func f(m sync.Mutex) |
复制锁,调用方与被调用方各锁各的 |
结构体按值传递 func (s S) Inc() |
同上(方法接收者复制) |
结构体里嵌 sync.Mutex 再拷贝结构体 |
整个状态被复制成两份 |
返回 sync.Once |
复制后"只执行一次"变成"各执行一次" |
go vet 能查前两类(copylocks 检查),但它查不到"设计上本来就该用指针"的情况——例如把含 mutex 的结构体放进 map[string]S,每次读出来都是副本,看起来完全合法。
// 症状:加了锁,计数还是错
type Counter struct { mu sync.Mutex; n int }
func (c Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() } // 值接收者 → 复制
// 修法:全部改成指针接收者,并且不要按值存进容器
func (c *Counter) Inc() { c.mu.Lock(); c.n++; c.mu.Unlock() }
4. -race 能发现什么、发现不了什么
竞态检测器基于动态的 happens-before 分析:它记录每次访问与同步事件,在运行时判断"这两个访问之间有没有顺序保证"。所以它的性质很明确:
| 性质 | 含义 |
|---|---|
| 零假阳性 | 报了就是真竞争(不会误报) |
| 零覆盖保证 | 没报不等于没有(只覆盖执行到的路径) |
| 开销大 | 5–20 倍 CPU、5–10 倍内存,不能常开在生产 |
| 依赖调度 | 没并发的两条路径,永远测不出 |
第二行是关键:-race 不是静态分析,它只对"这次运行真的并发访问了"的代码生效。所以正确用法是在集成测试里跑,并且刻意提高并发度与循环次数,而不是跑单元测试。
// 正确的跑法:并发压测 + 多次循环,比单测覆盖率重要
$ go test -race -count=20 -parallel=8 ./internal/... 2>&1 | tee race.log
$ grep -A6 "WARNING: DATA RACE" race.log | head -30
// 输出会给出两个访问点的完整调用栈——这才是修 bug 的入口
// 生产环境不要常开;必要时用 -race 的灰度实例做短时观测
5. 原子操作解决的是单变量,不解决一致性
atomic 很容易被当成"轻量锁"。它保证的是单个变量的读写原子性与顺序,不保证多个变量之间的关系。
| 需求 | atomic 够吗 |
说明 |
|---|---|---|
| 计数器 | 够 | atomic.Int64.Add(1) |
| 开关标志 | 够 | atomic.Bool |
| 指针整体替换(发布新配置) | 够 | atomic.Pointer[Config] |
| 两个字段必须同时更新 | 不够 | 需要锁,或用不可变结构整体替换 |
| 读-判断-写 | 不够 | 需要 CompareAndSwap 循环或锁 |
第四行是常见事故:{count, sum} 两个字段分别用 atomic 更新,读者可能看到"count 已加、sum 还没加"的中间态,于是平均值瞬间偏离。
// 整体替换(不可变结构 + atomic.Pointer)是"无锁发布"的标准做法
type Stats struct { Count int64; Sum int64 } // 不可变
var cur atomic.Pointer[Stats]
func add(d int64) {
for {
old := cur.Load()
next := &Stats{Count: old.Count + 1, Sum: old.Sum + d}
if cur.CompareAndSwap(old, next) { return }
}
}
// 读者永远看到"一致的快照",不会看到半更新
6. 排错:五种"加了锁还是错"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 计数少算 | 读-改-写未受保护,或复制了 mutex | 用指针接收者 + 同一把锁 |
| 偶发读到 nil 字段 | 发布未同步 | atomic.Pointer 或 channel 发布 |
fatal error: concurrent map writes |
未加锁的 map | sync.Map 或加锁(前者只在特定读多场景更优) |
-race 报了但代码"看起来有锁" |
读写用了不同的锁实例 | 检查是否复制了结构体 |
| 只在多核机器复现 | 单核掩盖了竞争 | CI 加 -race + 高并发用例 |
第四行值得展开:mu1.Lock() 与 mu2.Lock() 保护同一个字段,是最常见的"有锁但无效"。而它在单线程/低并发下完全正常——因为没并发就检查不出来。
7. 边界:什么情况"不加同步也行"
| 场景 | 为什么可行 | 前提 |
|---|---|---|
只读的初始化数据(init 里赋值) |
包初始化有顺序保证 | 之后不再写 |
| 每个 goroutine 独占的局部变量 | 无共享 | 不外逃 |
| 通过 channel 传递所有权 | 传递即同步 | 传后原持有者不再碰 |
| 不可变数据 + 整体替换 | 读者只见完整版本 | 替换用 atomic |
| 单 goroutine 访问的字段 | 无并发 | 明确文档化 |
判断口径一句话:并发正确性不是"大多数时候对",而是"没有任何一次可以错"——没有 happens-before 保证的共享访问,语言允许它给出任意结果,而你无法用测试证明它不会。所以真判据是"这个字段是否只被一个 goroutine 写、且读者有同步手段",答不出就是竞争。
8. 自测
- 数据竞争为什么不是"值算错"而是"程序可以给出任意结果"?
- happens-before 的四条主要来源是什么?它们各自只对什么范围生效?
- 写侧加锁、读侧不加锁,为什么仍然是竞争?
- 值接收者方法为什么会"复制锁"?
go vet能查出哪一类、查不出哪一类? -race的"零假阳性、零覆盖"分别意味着什么?为什么要在集成测试而不是单测里跑?atomic能解决{count, sum}的一致性吗?不能的话用什么模式?
9. 一句话总结
数据竞争是"缺少 happens-before 关系",不是"同时写":语言不保证无同步访问的结果,所以它可以偶发、可以只在多核出现、可以被编译器优化放大。四类同步手段(channel、Mutex、WaitGroup、Once)各自只对自身范围建立顺序,读侧不加同一把锁就等于没加。判据:确认每个共享字段"只被一个 goroutine 写、且读者有同步",做不到就上锁或改不可变 + 整体替换。