2573 字
约 8 分钟
12
S09-A27 · 数据竞争:为什么"测试全过、线上偶发错值"
2024-12-31
2026-11-11

一个计数器偶发少算:线上每天错几十次,本地一万次跑一次都不错。加了一个 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. 自测

  1. 数据竞争为什么不是"值算错"而是"程序可以给出任意结果"?
  2. happens-before 的四条主要来源是什么?它们各自只对什么范围生效?
  3. 写侧加锁、读侧不加锁,为什么仍然是竞争?
  4. 值接收者方法为什么会"复制锁"?go vet 能查出哪一类、查不出哪一类?
  5. -race 的"零假阳性、零覆盖"分别意味着什么?为什么要在集成测试而不是单测里跑?
  6. atomic 能解决 {count, sum} 的一致性吗?不能的话用什么模式?

9. 一句话总结

数据竞争是"缺少 happens-before 关系",不是"同时写":语言不保证无同步访问的结果,所以它可以偶发、可以只在多核出现、可以被编译器优化放大。四类同步手段(channel、Mutex、WaitGroup、Once)各自只对自身范围建立顺序,读侧不加同一把锁就等于没加。判据:确认每个共享字段"只被一个 goroutine 写、且读者有同步",做不到就上锁或改不可变 + 整体替换。

S09-A27 · 数据竞争:为什么"测试全过、线上偶发错值"
https://www.clxhxhhr.top/posts/6732/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。