前面的文章我们讲了 Mutex、Channel、atomic 等各种并发工具,但有一个更底层的问题我们还没回答——一个 goroutine 写入的值,另一个 goroutine 什么时候能看到? 这就是 Go 内存模型(The Go Memory Model)要回答的问题。
注意:这里的"内存模型"不是内存分配/回收,而是 并发环境下变量的可见性规则。
一、为什么需要内存模型?
直觉上,代码应该按你写的顺序执行。但现实中有两个因素会打破这种直觉:
编译器重排
CPU 多级缓存
看一个例子:
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 的新值。
二、Happens-Before 关系
Go 内存模型的核心概念是 happens-before(先于发生)关系。如果事件 A happens-before 事件 B,那么 A 的效果(内存写入)对 B 一定可见。
- 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()2. goroutine 创建
go 语句 happens-before 新 goroutine 的执行开始:
var a int
a = 1
go func() {
fmt.Println(a) // ✅ 保证能看到 a = 1
}()3. Channel 操作
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规则 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 规则,你就能准确判断并发代码的正确性,而不是靠"跑了一百次都没出错"来给自己信心。