学习目标
- 理解什么是数据竞争(race condition)
- 识别 Go 并发中最常见的竞态场景
- 掌握 Go race detector 的作用与使用方式
- 学会写出并发安全(race-free)的 Go 代码
一、什么是数据竞争(Race Condition)
在 Go 中,数据竞争指的是:
多个 goroutine 在没有正确同步的情况下,同时访问同一份共享数据,且至少有一个是写操作。
这是一类未定义行为,其结果可能包括:
- 读到脏数据
- 状态不一致
- 程序行为随机、不可复现
- 线上偶发、难以定位的 bug
工程共识:只要存在数据竞争,程序就是不正确的。
二、最典型的数据竞争示例
示例:
var count int
func main() {
for i := 0; i < 2; i++ {
go func() {
count++
}()
}
time.Sleep(time.Second)
fmt.Println(count)
}问题点:
count++不是原子操作- 多个 goroutine 同时读写
count
可能结果:
- 1
- 2
工程结论:只要你看到共享变量 + goroutine,就要立刻警惕。
三、数据竞争为什么如此危险
数据竞争的危险之处在于:
- 本地可能完全复现不了
- 线上偶发出现
- 加日志后“消失”(时序被改变)
核心原因:
- CPU 指令重排
- 缓存一致性
- 调度时机不可预测
这也是为什么:“看起来没问题”的并发代码,依然是错的。
四、Go 的 race detector(必须会用)
Go 内置了数据竞争检测器。
启用方式:
go test -race ./...或:
go run -race main.go当检测到竞态时,会输出:
- 冲突的读写位置
- 对应的 goroutine 栈信息
工程建议:
- 开发阶段经常开启 -race
- CI 中至少跑一次 race 测试
五、常见的数据竞争场景
场景 1:未加锁的共享变量
var x int
go func() { x = 1 }()
go func() { fmt.Println(x) }()场景 2:并发读写 map
m := make(map[string]int)
go func() { m["a"] = 1 }()
go func() { _ = m["a"] }()结果:可能直接 panic。
场景 3:slice 并发 append
s := []int{}
go func() { s = append(s, 1) }()
go func() { s = append(s, 2) }()结果:数据错乱或丢失。
六、解决数据竞争的三种基本方式
方式一:使用 mutex 保护共享数据
var (
mu sync.Mutex
x int
)
mu.Lock()
x++
mu.Unlock()适用场景:
- 共享状态
- 频繁读写
方式二:使用 channel 串行化访问
ch := make(chan int)
go func() {
for v := range ch {
fmt.Println(v)
}
}()
ch <- 1
ch <- 2适用场景:
- 事件流
- 任务协作
方式三:避免共享(最理想)
设计原则:
- 数据只属于一个 goroutine
- 通过消息传递结果
工程共识:不共享,就没有竞争。
七、原子操作(sync/atomic)的定位
Go 提供了 sync/atomic 包:
atomic.AddInt64(&counter, 1)适用场景:
- 简单计数器
- 状态标志位
注意:
- 原子操作不等于“复杂逻辑安全”
- 多个原子操作组合,依然可能有竞态
八、并发安全的工程设计原则
- 共享数据一定要有“唯一保护方式”
- 不要混用 mutex 和 channel 保护同一数据
- 优先选择简单、可读的同步方式
- 通过设计避免共享,而不是事后补锁
工程结论:并发安全首先是设计问题,其次才是工具问题。
九、并发安全总结
- 数据竞争是 Go 并发中最危险的问题之一
- race detector 是你的第一道防线
- mutex、channel、atomic 各有适用边界
- “不共享”是终极解决方案
一句话总结:
并发不可怕,失控的并发才可怕。
预告:Day 18|sync/atomic 与无锁编程基础
下一天将系统讲解:
- atomic 的使用场景
- 原子操作与内存可见性
- 为什么“无锁”并不一定更快
- 工程中如何正确使用 atomic