前面的文章我们讲了 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 = 1 在 b = 2 之前执行。
但在没有同步原语的情况下,Go 内存模型不保证这一点。 编译器可能重排 a = 1 和 b = 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 = 14. 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) // ✅ 一定是 16. 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 send | send hb 对应 recv 完成 |
| Channel close | close hb recv 返回零值 |
| 无缓冲 Channel recv | recv hb 对应 send 完成 |
| 有缓冲 Channel(容量 C) | 第 i 次 recv hb 第 i+C 次 send 完成 |
| Mutex Unlock | 第 n 次 Unlock hb 第 n+1 次 Lock |
| RWMutex | RUnlock hb 后续 Lock;Unlock hb 后续 RLock |
| Once.Do(f) | f 完成 hb 任何 Do 返回 |
| atomic | 顺序一致的全局总序 |
| WaitGroup | Done hb 对应的 Wait 返回 |
六、实战建议
- 不要假设执行顺序——没有同步就没有保证,即使"看起来"一定先执行
- 不要使用裸变量在 goroutine 之间通信——必须通过 Channel、Mutex、atomic 等同步原语
- 优先用高层原语——Channel > Mutex > atomic,越底层越容易出错
- 用
-race检测数据竞争——编译器能发现你肉眼看不到的竞争
# race detector 是你最好的朋友
go test -race ./...
go run -race main.go
# 两个应该加入 CI 的命令
go vet ./...
go test -race ./...- 理解 happens-before 的"非对称性"——goroutine 创建 hb goroutine 开始,但 goroutine 退出不 hb 任何东西(需要 WaitGroup 或 Channel 来同步)
Go 内存模型是并发编程的"宪法"——它定义了什么是被允许的,什么是有保证的。理解 happens-before 规则,你就能准确判断并发代码的正确性,而不是靠"跑了一百次都没出错"来给自己信心。