跳至内容
从 happens-before 真正理解 Go 内存模型

从 happens-before 真正理解 Go 内存模型

2026年6月25日·
yanlong

并发代码里最危险的一句话是:“另一个 goroutine 应该已经执行完了。”内存模型不讨论“应该”,它只回答一件事:某次读是否被保证能观察到某次写。

在 Go 中,只要多个 goroutine 并发访问同一内存,且至少一个是写,就必须通过 channel、锁、atomic 等同步手段建立顺序。没有同步,就不仅是“可能读到旧值”,而是数据竞争。

两种顺序组成 happens-before

可以先用非形式化的方式理解:

  1. sequenced-before:同一个 goroutine 内,按语言规定的执行顺序发生;
  2. synchronized-before:由 channel、锁、atomic 等同步操作在 goroutine 之间建立。

happens-before 是这两种关系合并后的传递闭包。

    flowchart LR
  W["G1: data = ready"] -->|程序顺序| S["G1: close(done)"]
  S -->|同步关系| R["G2: <-done"]
  R -->|程序顺序| P["G2: print(data)"]
  

因为写入 happens-before 打印,G2 保证看到 ready

启动 Goroutine 只保证一个方向

go f() 的启动发生在 f 开始执行之前,因此下面的参数准备是安全的:

value := buildValue()
go consume(value)

但 goroutine 的退出不会自动同步回启动方:

var message string

func main() {
    go func() {
        message = "ready"
    }()

    fmt.Println(message) // 数据竞争,不保证输出 ready
}

time.Sleep 也没有建立内存模型关系。调度上“通常足够久”不是同步。应使用 channel 或 WaitGroup:

var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    message = "ready"
}()
wg.Wait()
fmt.Println(message)

Done 同步先于它解除阻塞的 Wait 返回。

Channel 的同步规则

发送先于对应接收完成

data = "ready"
ch <- struct{}{}

如果另一个 goroutine 完成 <-ch,它之后读取 data 能看到此前写入。

close 先于观察到关闭的接收

这使关闭 channel 能作为广播屏障:

config = loaded
close(ready)

// 任意 goroutine
<-ready
use(config)

无缓冲 Channel 的反向保证

对无缓冲 channel,接收发生在对应发送完成之前。发送方返回时,接收方已经到达会合点。

缓冲 Channel 还有一条容量规则

容量为 C 的 channel 上,第 k 次接收 happens-before 第 k+C 次发送完成。这条规则让缓冲 channel 可以实现计数信号量:释放一个槽位发生在后续任务成功占用该槽位之前。

注意:缓冲 channel 的“发送完成”不代表接收方已经处理了数据。容量允许发送方提前继续,这正是缓冲的意义。

Mutex 的可见性保证

对同一把 Mutex,一次 Unlock happens-before 后续成功的 Lock。因此锁不仅防止同时进入临界区,也负责发布写入:

mu.Lock()
config = next
mu.Unlock()

// 另一个 goroutine
mu.Lock()
current := config
mu.Unlock()

两个 goroutine 使用不同的锁保护同一个变量没有意义。同步关系必须通过同一个原语建立。

TryLock 失败不会建立任何同步关系。不能因为“尝试过锁”就读取受保护状态。

Once 发布初始化结果

once.Do(f)f 的完成 happens-before 任意一次 once.Do 返回。调用方不需要在 Once 外再加锁读取初始化结果:

var (
    once   sync.Once
    config *Config
)

func getConfig() *Config {
    once.Do(func() {
        config = loadConfig()
    })
    return config
}

自己用一个普通 bool 做双重检查,如果没有正确的 atomic 或锁,会产生数据竞争,也可能看到“ready 为 true,但对象字段还没安全发布”的状态。

Atomic 提供顺序一致性

Go 的 atomic 操作表现得像按某个全局的顺序一致次序执行。如果 atomic 操作 A 的效果被 B 观察到,A synchronized-before B。

var ready atomic.Bool
var config *Config

// 发布方
config = loadConfig()
ready.Store(true)

// 读取方
if ready.Load() {
    use(config)
}

这段代码在内存可见性上可以成立,但设计上通常不如 atomic.Pointer[Config] 或 Once 直接。内存模型正确不代表 API 清楚。

无数据竞争程序的关键保证

Go 提供 DRF-SC:没有数据竞争的程序,其行为可以解释为多个 goroutine 操作按某种顺序一致的方式交错执行。

这就是为什么官方内存模型反复强调“不要聪明过头”。与其推演某个 CPU 是否会重排,不如使用明确同步原语,让程序进入无数据竞争的世界。

如果存在数据竞争,不能拿某次实验结果证明代码安全:

  • 编译器优化会改变访问方式;
  • CPU 和架构的内存行为不同;
  • 增加日志可能恰好改变调度;
  • 竞态检测器没有在某次运行中报告,不代表所有路径都覆盖。

常见的伪同步

下面这些都不建立可靠的 happens-before:

  • time.Sleep
  • 观察 goroutine 数量;
  • 认为单字长读写“在机器上是原子的”;
  • 两边各用一把不同的锁;
  • 轮询普通 bool;
  • 依赖日志或 fmt 调用造成的偶然调度。

业务代码需要的是语言保证,不是当前实现的运气。

用 race detector 佐证,而不是替代设计

go test -race ./...

竞态检测器会观察实际运行路径中的冲突访问。它非常有价值,但只能发现被执行到的竞态。测试覆盖、压力测试和正确的同步设计缺一不可。

读并发代码的方法

审查一段并发代码时,可以沿着数据而不是 goroutine 读:

  1. 哪些内存会被多个 goroutine 访问?
  2. 哪些访问包含写?
  3. 它们通过哪个具体同步操作建立顺序?
  4. 这个顺序是否覆盖所有退出、超时和错误路径?

如果第三个问题的答案是“它们执行得很快”或“通常先跑这里”,代码就还没有同步完成。

延伸阅读