第18章 Go 内存模型与数据竞争(重点)
本章讨论的是 Go 语言保证的可见性与顺序,不是某个 CPU 的缓存实现。代码没有建立 happens-before 关系时,不能用“在我的机器上总是正常”证明它正确。
并发程序最危险的误区,是把源码顺序等同于其他 goroutine 的观察顺序。编译器、CPU 和 Runtime 都可以在不改变单 goroutine 语义的前提下重排操作。Go 内存模型回答两个问题:
- 一个 goroutine 的写入何时保证对另一个 goroutine 可见?
- 哪些同步操作建立这种保证?
18.1 数据竞争
当两个 goroutine 并发访问同一内存位置,至少一个是写,并且访问没有通过同步排序时,就发生 data race:
var ready bool
var value int
go func() {
value = 42
ready = true
}()
for !ready {
}
fmt.Println(value)
这段代码有两个竞争。即使读到了 ready == true,也没有语言保证随后能读到 value == 42。循环还可能被编译器按无同步代码优化。
Go 对无竞争程序提供 DRF-SC:如果程序没有数据竞争,它的执行结果可以按 goroutine 操作的某种顺序一致交错来理解。对普通业务代码,最可靠的策略就是消除所有 race。
18.2 happens-before
happens-before 是两类顺序关系的传递闭包:
- sequenced-before:同一 goroutine 内由语言语义确定的顺序。
- synchronized-before:channel、锁、原子操作等同步事件建立的跨 goroutine 顺序。
若写 W happens-before 读 R,R 就必须观察到 W 或其后的写。仅有墙上时钟先后、time.Sleep、日志输出或“这个 goroutine 通常先跑”都不构成同步。
18.3 goroutine 的启动与退出
go f() 的启动发生在 f 开始执行之前,因此启动前准备的数据可以安全读取:
config := Config{Port: 8080}
go serve(config) // config 的构造先于 serve 开始
但 goroutine 退出本身不建立任何同步关系:
var result int
go func() { result = 42 }()
// 即使经验上 goroutine 已经结束,直接读仍然是 race。
fmt.Println(result)
必须用 channel、WaitGroup、锁或其他同步原语等待。
18.4 Channel 的同步保证
无缓冲 channel 的一次发送 synchronized-before 对应接收完成:
var result int
done := make(chan struct{})
go func() {
result = 42
close(done)
}()
<-done
fmt.Println(result) // 安全:close 之前的写入对接收方可见
主要规则如下:
| 操作 | 保证 |
|---|---|
| 向 channel 发送 | 先于对应接收完成 |
close(ch) | 先于因关闭而返回零值的接收 |
| 无缓冲 channel 接收 | 先于对应发送完成 |
| 容量 C 的 channel | 第 k 次接收先于第 k+C 次发送完成 |
最后一条解释了为什么带缓冲 channel 可用作计数信号量:释放一个槽位发生在后续占用该槽位之前。
不要把 len(ch) 当同步判断。读取长度后,其他 goroutine 可以立即改变状态;它只适合监控和启发式决策。
18.5 Mutex、RWMutex 与 Once
对同一个 sync.Mutex,第 n 次 Unlock synchronized-before 后续第 m 次 Lock 返回。临界区内的写入因此对后续持锁者可见:
type Cache struct {
mu sync.RWMutex
value string
}
func (c *Cache) Set(value string) {
c.mu.Lock()
c.value = value
c.mu.Unlock()
}
func (c *Cache) Get() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.value
}
锁保护的是不变量,不只是某个字段。读写同一组状态时应始终使用同一把锁。
sync.Once 保证 f 的返回先于任何 Do(f) 返回。若 f panic,Once 仍把它视为已经返回;后续 Do 不会重试。需要每次调用都重放同一 panic 时使用 sync.OnceFunc。
18.6 WaitGroup、Cond 与 Pool
Done(即 Add(-1))synchronized-before 它解除阻塞的 Wait 返回。Go 1.25 的 WaitGroup.Go 还保证任务函数返回先于对应 Wait 返回:
var wg sync.WaitGroup
var result int
wg.Go(func() { result = 42 })
wg.Wait()
fmt.Println(result) // 安全
sync.Cond.Broadcast 或 Signal 先于它解除阻塞的 Wait。但条件必须在关联锁保护下用循环检查,因为唤醒不等于条件仍然成立:
c.L.Lock()
for !condition() {
c.Wait()
}
useState()
c.L.Unlock()
sync.Pool.Put(x) 先于返回同一个 x 的 Get。这只保证对象交接,不保证 Pool 一定保留对象;GC 或 Runtime 可随时丢弃池中条目。
18.7 原子操作
sync/atomic 的操作按顺序一致语义参与一个全局顺序。若原子写 A 被原子读 B 观察到,A 之前的普通写对 B 之后的普通读可见:
type Snapshot struct {
Values []int
}
var current atomic.Pointer[Snapshot]
func Publish(values []int) {
next := &Snapshot{Values: slices.Clone(values)}
current.Store(next)
}
func Load() *Snapshot {
return current.Load()
}
发布后不得再修改 Snapshot 或其切片,否则读者仍会 race。atomic 解决的是指针发布,不会自动让对象内部可变状态线程安全。
Go 1.23 增加了整数原子类型的 And 和 Or。位标志适合原子操作,但跨多个变量的不变量仍应使用锁。
18.8 安全发布模式
常用的安全发布方式包括:
- 在启动 goroutine 前构造完整对象。
- 通过 channel 发送对象或关闭 done channel。
- 在同一把锁下写入和读取。
- 用
sync.Once初始化。 - 用
atomic.Pointer或atomic.Value发布不可变快照。 - 用
WaitGroup等待写入者结束。
下面的手写双重检查是错误的:
if instance == nil { // 与下面的写竞争
mu.Lock()
if instance == nil {
instance = newInstance()
}
mu.Unlock()
}
使用 sync.OnceValue:
var load = sync.OnceValue(func() *Config {
return readConfig()
})
18.9 所有权优于共享可变状态
同步规则最简单的代码通常让一个 goroutine 独占可变对象,其他 goroutine 通过消息请求操作:
type command struct {
key string
value string
}
func runStore(commands <-chan command) {
values := make(map[string]string)
for command := range commands {
values[command.key] = command.value
}
}
这不是说 channel 总优于锁。短临界区和共享索引用锁更直接;流水线、任务所有权转移和取消广播更适合 channel。选择依据是不变量是否容易表达,而不是口号。
18.10 Race Detector
Race detector 通过插桩记录内存访问和同步事件:
go test -race ./...
go test -race -count=10 ./path/to/hot/package
go run -race ./cmd/server
它只能发现实际执行路径上的 race。要提高覆盖率:
- 在 CI 跑单元和集成测试的
-race版本。 - 对并发测试增加多次运行和压力输入。
- 覆盖超时、取消、错误返回和关闭顺序。
- 在支持的 GOOS/GOARCH 上运行真实工作负载。
不要用 time.Sleep 修复测试时序。Sleep 只改变概率,不建立同步。使用 channel、WaitGroup,或 Go 1.25 的 testing/synctest 控制并发测试。
Race detector 也不是完整证明:未执行分支、C 代码中的访问和某些 unsafe 行为可能不可见。即使测试无报告,代码审查仍要找出明确的 happens-before 链。
18.11 常见错误
| 错误 | 为什么不成立 | 正确方式 |
|---|---|---|
time.Sleep 后读取结果 | 时间先后不是同步 | channel/WaitGroup |
| 一个 goroutine 写,其他只读,所以安全 | 读写仍构成 race | 发布不可变快照 |
GOMAXPROCS=1 没有并发 | goroutine 仍可交错,Runtime 也会调度 | 正确同步 + -race |
| 只把写操作放在锁里 | 无锁读与写竞争 | 所有访问遵守同一协议 |
| atomic 指针指向可变 map | atomic 只保护指针本身 | copy-on-write 或锁 |
检查 len(ch) 再发送 | 状态可在检查后改变 | 非阻塞 select 或协议设计 |
| 复制 Mutex/Once/atomic | 同步状态被拆成两份 | 使用指针,运行 go vet |
本章小结
- 无同步的跨 goroutine 读写是 data race;无竞争程序才享有可按顺序一致方式理解的保证。
- happens-before 由 goroutine 内顺序和同步操作共同建立,Sleep 和经验时序不算同步。
- channel、锁、Once、WaitGroup、Pool 和 atomic 都有精确而不同的同步保证。
- 安全发布通常依赖不可变对象、明确所有权和一条可说明的 happens-before 链。
-race是必要的动态检查,但不能替代内存模型推理和代码审查。
进一步阅读: