Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

第18章 Go 内存模型与数据竞争(重点)

本章讨论的是 Go 语言保证的可见性与顺序,不是某个 CPU 的缓存实现。代码没有建立 happens-before 关系时,不能用“在我的机器上总是正常”证明它正确。

并发程序最危险的误区,是把源码顺序等同于其他 goroutine 的观察顺序。编译器、CPU 和 Runtime 都可以在不改变单 goroutine 语义的前提下重排操作。Go 内存模型回答两个问题:

  1. 一个 goroutine 的写入何时保证对另一个 goroutine 可见?
  2. 哪些同步操作建立这种保证?

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.BroadcastSignal 先于它解除阻塞的 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 增加了整数原子类型的 AndOr。位标志适合原子操作,但跨多个变量的不变量仍应使用锁。

18.8 安全发布模式

常用的安全发布方式包括:

  • 在启动 goroutine 前构造完整对象。
  • 通过 channel 发送对象或关闭 done channel。
  • 在同一把锁下写入和读取。
  • sync.Once 初始化。
  • atomic.Pointeratomic.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 指针指向可变 mapatomic 只保护指针本身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 是必要的动态检查,但不能替代内存模型推理和代码审查。

进一步阅读: