前面的文章我们讲了 Mutex、Channel、atomic 等各种并发工具,但有一个更底层的问题我们还没回答——一个 goroutine 写入的值,另一个 goroutine 什么时候能看到? 这就是 Go 内存模型(The Go Memory Model)要回答的问题。

注意:这里的"内存模型"不是内存分配/回收,而是 并发环境下变量的可见性规则

一、为什么需要内存模型?

直觉上,代码应该按你写的顺序执行。但现实中有两个因素会打破这种直觉:

┌──────────────────────────────────────────────────────────────┐
│  打破执行顺序的两个因素                                       │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  1. 编译器重排                                               │
│     编译器为了优化性能,可能调整指令执行顺序                 │
│     只要在单 goroutine 内语义不变,就允许重排                │
│                                                              │
│  2. CPU 多级缓存                                             │
│     每个 CPU 核有自己的 L1/L2 缓存                           │
│     一个核写入的值不一定立即对其他核可见                     │
│                                                              │
└──────────────────────────────────────────────────────────────┘

看一个例子:

var a, b int

// goroutine 1
func f1() {
    a = 1
    b = 2
}

// goroutine 2
func f2() {
    if b == 2 {
        fmt.Println(a) // 一定是 1 吗?
    }
}

直觉说:如果 b == 2,那 a 一定是 1,因为 a = 1b = 2 之前执行。

但在没有同步原语的情况下,Go 内存模型不保证这一点。 编译器可能重排 a = 1b = 2 的顺序;即使不重排,CPU 缓存也可能导致 goroutine 2 先看到 b 的新值,后看到 a 的新值。

goroutine 1 的可能执行顺序:

  编译器/CPU 重排后:
  b = 2  ← 先执行
  a = 1  ← 后执行

  goroutine 2 在这两条之间观测:
  b == 2 ✅ 但 a == 0 💀

二、Happens-Before 关系

Go 内存模型的核心概念是 happens-before(先于发生)关系。如果事件 A happens-before 事件 B,那么 A 的效果(内存写入)对 B 一定可见

┌──────────────────────────────────────────────────────────────┐
│  happens-before 的含义                                       │
├──────────────────────────────────────────────────────────────┤
│                                                              │
│  如果 A happens-before B:                                   │
│  • A 中对变量的写入,在 B 中一定可见                         │
│  • A 中的所有操作,在 B 开始前已经完成                       │
│                                                              │
│  如果 A 和 B 之间没有 happens-before 关系:                  │
│  • B 可能看到 A 的写入,也可能看不到                         │
│  • 这就是数据竞争                                           │
│                                                              │
└──────────────────────────────────────────────────────────────┘

单 goroutine 内的 happens-before

在同一个 goroutine 内,语句按代码顺序 happens-before:

// 同一个 goroutine
a = 1   // A
b = 2   // B:A happens-before B

// 编译器可能重排执行顺序,但保证语义等价
// 从这个 goroutine 的视角看,效果和代码顺序一致

跨 goroutine 的 happens-before

跨 goroutine 时,只有通过同步原语才能建立 happens-before 关系。没有同步,就没有保证。

三、Go 内存模型的具体保证

Go 内存模型为以下同步操作定义了 happens-before 关系:

1. init 函数

包 A 导入包 B
  → B 的 init() happens-before A 的 init()
  → 所有 init() happens-before main()

┌─────────────────────────────────────────┐
│  import graph:                          │
│                                         │
│  main ──→ pkg A ──→ pkg B              │
│                                         │
│  执行顺序:                             │
│  B.init() → A.init() → main.main()     │
│      ↑ happens-before ↑ happens-before  │
└─────────────────────────────────────────┘

2. goroutine 创建

go 语句 happens-before 新 goroutine 的执行开始:

var a int

a = 1
go func() {
    fmt.Println(a) // ✅ 保证能看到 a = 1
}()
  a = 1
    │ happens-before
  go func()
    │ happens-before
  goroutine 开始执行
    fmt.Println(a) // 一定是 1 ✅

3. Channel 操作

Channel 提供了最丰富的 happens-before 保证:

┌──────────────────────────────────────────────────────────────────┐
│  Channel 的 happens-before 规则                                  │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│  规则 1:send happens-before 对应的 recv 完成                    │
│                                                                  │
│  规则 2:close happens-before 从关闭 channel 的 recv 返回零值    │
│                                                                  │
│  规则 3(无缓冲):recv happens-before 对应的 send 完成          │
│                                                                  │
│  规则 4(有缓冲,容量 C):                                     │
│    第 i 次 recv happens-before 第 i+C 次 send 完成              │
│                                                                  │
└──────────────────────────────────────────────────────────────────┘

用规则 1 修复开头的例子:

var a int
ch := make(chan int)

// goroutine 1
go func() {
    a = 1
    ch <- 0  // send
}()

// goroutine 2
<-ch          // recv:send happens-before recv 完成
fmt.Println(a) // ✅ 保证看到 a = 1
  goroutine 1           goroutine 2
      │                     │
      │ a = 1               │
      │   │                 │
      │   │ hb              │
      │   ↓                 │
      │ ch <- 0 ─── hb ──→ <-ch
      │                     │
      │                     │ fmt.Println(a)
      │                     │ ✅ a == 1

规则 3(无缓冲的特殊保证)——recv happens-before send 完成:

var a int
ch := make(chan int) // 无缓冲

// goroutine 1
go func() {
    a = 1
    <-ch // recv(先于 send 完成)
}()

ch <- 0 // send
fmt.Println(a) // ✅ 保证看到 a = 1

4. Mutex

┌──────────────────────────────────────────────────────────┐
│  Mutex 的 happens-before 规则                             │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  第 n 次 Unlock happens-before 第 n+1 次 Lock            │
│                                                          │
│  也就是说:                                               │
│  Lock() 成功后,能看到上一次 Unlock() 之前的所有写入     │
│                                                          │
└──────────────────────────────────────────────────────────┘
var mu sync.Mutex
var a int

// goroutine 1
mu.Lock()
a = 1
mu.Unlock() // 第 1 次 Unlock

// goroutine 2
mu.Lock()   // 第 2 次 Lock(happens-after 第 1 次 Unlock)
fmt.Println(a) // ✅ 保证看到 a = 1
mu.Unlock()

5. Once

once.Do(f) 中 f 的完成 happens-before 任何 Do 调用的返回

// f 中的写入对所有 Do 返回后的读取可见
var a int
once.Do(func() { a = 1 })
fmt.Println(a) // ✅ 一定是 1

6. atomic

┌──────────────────────────────────────────────────────────┐
│  atomic 的 happens-before 规则(Go 1.19 明确化)         │
├──────────────────────────────────────────────────────────┤
│                                                          │
│  atomic 操作表现得像是在一个顺序一致的全局总序中执行     │
│                                                          │
│  具体来说:                                              │
│  atomic.Store happens-before 后续的 atomic.Load          │
│  (如果 Load 读到了 Store 的值)                         │
│                                                          │
└──────────────────────────────────────────────────────────┘

四、经典反例

反例 1:没有同步就没有保证

var a, b int

// goroutine 1
go func() {
    a = 1
    b = 2
}()

// goroutine 2
go func() {
    if b == 2 {
        fmt.Println(a) // ❌ 可能是 0!
    }
}()

没有任何同步原语,b == 2 不能保证 a == 1。

反例 2:双重检查锁定(Double-Check Locking)

// ❌ 错误的双重检查
var instance *Singleton

func GetInstance() *Singleton {
    if instance != nil { // 第一次检查:无锁
        return instance   // ❌ 可能看到部分初始化的对象!
    }
    mu.Lock()
    defer mu.Unlock()
    if instance == nil {
        instance = newSingleton() // 写入
    }
    return instance
}

问题在于:goroutine A 在锁内给 instance 赋值,goroutine B 在锁外读取 instance。锁外的读取和锁内的写入之间没有 happens-before 关系,B 可能看到 instance 的指针不为 nil,但对象的字段还没初始化完。

正确做法:用 sync.Once

// ✅ Once 保证 happens-before
var once sync.Once
var instance *Singleton

func GetInstance() *Singleton {
    once.Do(func() {
        instance = newSingleton()
    })
    return instance // ✅ 保证看到完全初始化的对象
}

反例 3:忙等待

var ready int32

// goroutine 1
go func() {
    // 做一些初始化
    atomic.StoreInt32(&ready, 1)
}()

// goroutine 2
for atomic.LoadInt32(&ready) == 0 {
    // 忙等待(虽然用了 atomic,但浪费 CPU)
}
// ✅ 这里保证能看到 goroutine 1 在 Store 之前的写入

这段代码是正确的(atomic 保证了 happens-before),但忙等待浪费 CPU。更好的做法是用 Channel 或 Cond。

五、总结:happens-before 规则速查表

同步操作happens-before 关系
包初始化B.init() hb A.init() hb main()
goroutine 创建go 语句 hb goroutine 开始执行
goroutine 退出无保证(goroutine 退出不 hb 任何东西)
Channel sendsend hb 对应 recv 完成
Channel closeclose hb recv 返回零值
无缓冲 Channel recvrecv hb 对应 send 完成
有缓冲 Channel(容量 C)第 i 次 recv hb 第 i+C 次 send 完成
Mutex Unlock第 n 次 Unlock hb 第 n+1 次 Lock
RWMutexRUnlock hb 后续 Lock;Unlock hb 后续 RLock
Once.Do(f)f 完成 hb 任何 Do 返回
atomic顺序一致的全局总序
WaitGroupDone hb 对应的 Wait 返回

六、实战建议

  1. 不要假设执行顺序——没有同步就没有保证,即使"看起来"一定先执行
  2. 不要使用裸变量在 goroutine 之间通信——必须通过 Channel、Mutex、atomic 等同步原语
  3. 优先用高层原语——Channel > Mutex > atomic,越底层越容易出错
  4. -race 检测数据竞争——编译器能发现你肉眼看不到的竞争
# race detector 是你最好的朋友
go test -race ./...
go run -race main.go

# 两个应该加入 CI 的命令
go vet ./...
go test -race ./...
  1. 理解 happens-before 的"非对称性"——goroutine 创建 hb goroutine 开始,但 goroutine 退出不 hb 任何东西(需要 WaitGroup 或 Channel 来同步)

Go 内存模型是并发编程的"宪法"——它定义了什么是被允许的,什么是有保证的。理解 happens-before 规则,你就能准确判断并发代码的正确性,而不是靠"跑了一百次都没出错"来给自己信心。