数据竞争与竞争条件
数据竞争(Data Race)和竞争条件(Race Condition)是并发编程中两个常被混用、实则不同层面的缺陷。
- 「数据竞争」发生在「内存层面」:多个 goroutine 对同一地址的访问之间没有同步关系,其中至少一次是写操作,结果不再具有可依赖的内存模型保证。
- 「竞争条件」发生在「逻辑层面」:程序结果的正确性依赖执行顺序,即使每次内存访问都有同步,顺序不同仍可能得到错误结果。
前者用锁、原子操作或 channel 等同步机制消除,后者往往要改数据结构或执行流。
数据竞争:内存访问缺少同步
Go 内存模型要求:两个 goroutine 对同一变量的访问,若至少一个是写操作且两者之间没有 happens-before 关系,则该访问是数据竞争。go test -race / go run -race 可以检测这类冲突。
NOTE
happens-before(先行发生)是内存模型判断两个操作顺序的规则:若操作 A happens-before 操作 B,则 A 对共享内存的写入在 B 中可见,且 B 不会观察到 A 之后的乱序结果。具体关系具有方向性:启动 goroutine 的 go 语句先于新 goroutine 的执行;一次 Unlock 先于随后成功的 Lock 返回;channel 发送先于对应接收完成;当一个原子操作观察到另一个原子操作的结果时,两者之间也会建立同步关系。两个操作之间没有这种关系时,对同一变量的并发访问就是数据竞争。
数据竞争在 happens-before 关系图里就是缺失的边:A、B 各自内部的操作由"程序顺序"连成链,但跨 goroutine 的两次写之间没有边。
两次写回都基于同一个旧值 1,会互相覆盖。引入同步后,channel 的发送和接收在两条链之间补上一条边:
边连成链后,A 的写入沿链传递到 B,读取一定可见;缺这条边就回到上面数据竞争的局面。
最常见的场景是多个 goroutine 同时执行 count++,这是读、加一、写回三步,互相交错时会丢失更新:
const N = 10000
var count int
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // 无同步:数据竞争
}()
}
wg.Wait()
fmt.Println(count) // 期望 10000这类程序可能输出小于 10000 的结果,-race 同时会报告冲突:
==================
WARNING: DATA RACE
Read at 0x00c000012168 by goroutine 23:
main.main.func1()
main.go:12 +0x74
Previous write at 0x00c000012168 by goroutine 7:
main.main.func1()
main.go:12 +0x98
==================报告给出了冲突的内存地址、两个操作的调用栈和 goroutine 创建点,可以直接定位到代码行。修复方式是给读写加上同一把锁:
mu.Lock()
count++
mu.Unlock()加锁后结果稳定为 10000。
竞争条件:结果依赖执行顺序
竞争条件不要求内存访问冲突。只要"判断"和"行动"之间存在窗口,让另一个 goroutine 改变了判断依据,结果就依赖调度顺序。
转账可以用来说明“没有数据竞争”不代表所有并发语义都已经确定。下面的代码按账户 ID 固定加锁顺序,同时锁住两个账户;-race 检查通过,每次转账相对于其他 Transfer 调用也是原子的:
type Account struct {
id int
balance int
mu sync.Mutex
}
/*
Transfer 把 amount 从 src 转给 dst:
src 是扣款方(转出账户),dst 是入账方(转入账户)
*/
func (src *Account) Transfer(dst *Account, amount int) {
// 按 id 固定加锁顺序,避免 src→dst 与 dst→src 并发时互相等待而死锁
first, second := src, dst
if first.id > second.id {
first, second = second, first
}
first.mu.Lock()
defer first.mu.Unlock()
second.mu.Lock()
defer second.mu.Unlock()
if src.balance >= amount {
src.balance -= amount
dst.balance += amount
}
}下面的 main 可以完整运行:初始两个账户各 1000,双方各发起 1000 笔 100 的转账,转账需求远超余额,因此哪些转账成功取决于调度顺序:
func main() {
a := &Account{id: 1, balance: 1000}
b := &Account{id: 2, balance: 1000}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(2)
go func() { defer wg.Done(); a.Transfer(b, 100) }()
go func() { defer wg.Done(); b.Transfer(a, 100) }()
}
wg.Wait()
fmt.Println(a.balance, b.balance)
}go run -race 全程无数据竞争报告,但多次运行结果不同(实测出现 1000 1000、900 1100、600 1400、800 1200 等),因为哪些转账先获得锁、哪些因余额不足被跳过,取决于调度顺序。只要业务允许“先到先得”,这种结果差异只是合法的调度差异,不是竞争条件缺陷。
如果业务要求不同执行顺序也必须得到同一个结果,就需要在业务层定义额外的顺序或预约机制;如果允许先到先得,则当前的双账户锁已经足够。真正危险的竞争条件通常出现在“先检查、后行动”之间没有覆盖完整不变量的场景,例如先单独检查余额,之后才调用另一个未持有同一组锁的扣款操作。
双重检查锁定的陷阱
懒加载初始化是双重检查锁定(Double-Checked Locking)的典型应用。用普通 bool 做初始化标志会踩两个坑:
var (
cache map[string]string
cacheLock sync.Mutex
initialized bool
)
func GetConfig(key string) string {
if !initialized { // 无同步读取:本身就是数据竞争
cacheLock.Lock()
defer cacheLock.Unlock()
if !initialized {
initializeCache()
initialized = true
}
}
return cache[key]
}外层 !initialized 是未同步读,-race 能直接报出;手写原子标志位虽然可行,但"先读标志、后读数据"的顺序容易写错。Go 中懒加载初始化的标准写法是 sync.Once,它同时保证"只初始化一次"和初始化结果的可见性:
var configOnce sync.Once
func GetConfig(key string) string {
configOnce.Do(initializeCache)
return cache[key]
}关系与检测
| 数据竞争 | 竞争条件 | |
|---|---|---|
| 层面 | 内存访问 | 程序逻辑 |
| 原因 | 访问之间缺少 happens-before | 结果依赖执行顺序 |
| 典型表现 | 丢失更新、观察到不可依赖的结果 | 成功与否、结果对错不确定 |
| 自动检测 | -race 可直接报出 | 纯逻辑层面无自动工具 |
| 解决手段 | 锁 / atomic / channel | 原子化业务操作、sync.Once、状态机 |
- 有数据竞争,程序就不再享有无数据竞争程序的顺序一致性保证;有竞争条件不一定有数据竞争。
-race检测的是运行期间实际发生并被检测到的内存访问冲突,未执行到的路径不会被报告,所以要在 CI 常态化运行,并尽量提高测试覆盖率。- 竞争条件如果同时伴随未同步访问,会被
-race一并报出(如上面的 DCL 示例);否则只能靠压力测试和代码审查。 - 针对竞争条件,可以重复运行并发测试(例如
go test -count=1000),并结合GOMAXPROCS或随机延迟改变调度条件;这些手段只能辅助暴露问题,不能替代对业务不变量的分析。
最佳实践
- 能用 channel 传递数据所有权,就不共享内存。
- 一次性初始化用
sync.Once,不要手写双重检查锁定。 - 锁保护的是不变量而不是单个变量,把判断和修改放进同一个临界区。
- 同时持有多个锁时固定获取顺序,避免死锁。
- 不用
time.Sleep充当同步手段,等待用 channel 或sync.Cond。
参考资料
- Go 官方:内存模型(Memory Model)
- Go 官方:竞态检测器
- Go 官方:sync 包