Go内存模型一致性
# 12.Go内存模型一致性
卷三第十二篇——Go 的内存模型不是关于「堆和栈怎么分配」的(那是 01 篇),而是关于多 goroutine 之间共享变量的可见性:A 在 goroutine A 中写了一个变量,goroutine B 什么时候能看到?答案不是「立刻」——是当存在 happens-before 关系时。这篇把 channel/mutex/atomic/once 四种同步原语的 happens-before 规则拆开,并讲清
go run -race怎么用 vector clock 抓到数据竞争。关键词:happens-before、synchronized-before、data race、ThreadSanitizer、Go 1.19 类型化 atomic。
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. Channel 的 happens-before 规则
- 4. Mutex 的 happens-before 规则
- 5. sync/atomic 的内存序
- 6. Once 与 WaitGroup 的 happens-before
- 7. Data Race 检测原理
- 8. 典型并发陷阱 Top 5
- 9. 与 Java / C++11 内存模型对照
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 一段崩在哪
看一个后台任务调度器——用 goroutine 并发处理任务,用共享变量做进度标记:
package main
import (
"fmt"
"sync"
"time"
)
var (
taskDone bool // 任务是否完成?
result string // 任务结果
)
func worker() {
// 模拟耗时任务
time.Sleep(100 * time.Millisecond)
result = "success"
taskDone = true
}
func reporter() {
for !taskDone {
time.Sleep(10 * time.Millisecond)
}
// taskDone 为 true 时读取 result
fmt.Println("result:", result)
}
func main() {
go worker()
go reporter()
time.Sleep(time.Second)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
现象:这个程序在 go run main.go 下多数时候输出 "result: success"——看起来没问题。但在 go run -race main.go 下,race detector 报出:
WARNING: DATA RACE
Write at 0x000001234568 by goroutine 6:
main.worker()
/app/main.go:14
Previous read at 0x000001234560 by goroutine 7:
main.reporter()
/app/main.go:19
2
3
4
5
6
7
8
在第 14 行(taskDone = true)和第 19 行(for !taskDone)之间存在 data race——两个 goroutine 并发访问 taskDone,至少一个在写,且没有 happens-before 关系。
更隐蔽的问题:即使 go run 下没崩溃,reporter 读到 taskDone == true 不等价于 result 已经可见——CPU 的写缓冲可能让 result = "success" 在 taskDone = true 之后才刷入主内存。这就是"指令重排"导致的可见性问题。
# 1.2 顺藤摸到根因
根因:两个 goroutine 之间没有建立 happens-before 关系。reporter 依赖 taskDone 看到 worker 的写入——但没有同步原语保证这个顺序。
在 Go 的内存模型中:
worker:
W(result) ← 写 result
W(taskDone) ← 写 taskDone
(没有同步操作!)
reporter:
R(taskDone) ← 读 taskDone
R(result) ← 读 result——可能看到旧值!
2
3
4
5
6
7
8
编译器或 CPU 可以把 taskDone = true 重排到 result = "success" 之前——因为单线程的"as-if-serial"保证只对 worker 自己有效,对 reporter 无效。
修复:使用 channel 或 sync.Mutex 建立 happens-before:
// 修复:用 channel 同步
done := make(chan struct{})
go func() {
result = "success"
close(done) // close(done) happens-before <-done 返回
}()
<-done
fmt.Println("result:", result) // 保证可见
2
3
4
5
6
7
8
# 1.3 我们要回答什么
这个事故藏着至少 7 个原理点:
① happens-before 到底是什么?和 CPU 的缓存一致性协议(MESI)是什么关系? → 第 2 章
② channel 的 send/recv/close 怎么建立 happens-before? → 第 3 章
③ sync.Mutex 的 Unlock 保证什么?Lock 之后一定能看到 Unlock 前的写入吗? → 第 4 章
④ atomic 操作的 happens-before 规则是什么?和 channel/mutex 有何不同? → 第 5 章
⑤ Once 和 WaitGroup 各自的 happens-before 保证? → 第 6 章
⑥ go run -race 怎么用 vector clock 检测数据竞争? → 第 7 章
⑦ Go 内存模型和 C++11/Java 的区别是什么?为什么不提供 memory_order? → 第 9 章
2
3
4
5
6
7
本篇路线:
happens-before 公理 (第 2 章) ── program order + synchronized order
↓
channel 规则 (第 3 章) ── send→recv, close→零值接收, 无缓冲第三条
↓
mutex 规则 (第 4 章) ── Unlock→Lock, RWMutex 扩展
↓
atomic 规则 (第 5 章) ── Go 1.19 类型化, 顺序一致 vs 松弛
↓
Once/WaitGroup (第 6 章) ── 初始化保证 + Wait 同步
↓
race detector (第 7 章) ── vector clock + ThreadSanitizer
↓
陷阱 + 对照 (第 8-9 章) ── Top 5 陷阱 + 与 C++11/Java 对比
↓
综合案例 (第 10 章) ── 完整修复 + 设计哲学
2
3
4
5
6
7
8
9
10
11
12
13
14
15
📌 本篇定位:这是 Go「并发编程」主题的收尾篇——把 channel/mutex/atomic/once 四种同步原语的 happens-before 规则编织成一张网。读完本篇,你能回答任何「这个并发代码正确吗」的问题——只要画出 goroutine 之间的 happens-before 有向图。
# 2. 架构概览
# 2.1 happens-before 的四个公理
Go 内存模型(2022 重写版)定义了两种顺序关系:
program order(程序顺序):
同一个 goroutine 内,代码的先后顺序
synchronized order(同步顺序):
不同 goroutine 之间的同步操作顺序
happens-before = program order + synchronized order 的传递闭包
2
3
4
5
6
7
公理 1:单 goroutine 内的 program order——在 goroutine A 中,如果代码行 1 在代码行 2 之前,package-level 初始化函数中的写入对 main 可见
公理 3:goroutine 创建——go f() 的调用 happens-before f() 的执行开始。即创建 goroutine 时的所有可见状态,新 goroutine 都能看到。
公理 4:goroutine 销毁——Go 不保证 goroutine 的退出对其他 goroutine 可见。不能用「goroutine 执行完了」作为同步信号——必须用显式的 channel/mutex/atomic。
# 2.2 为什么不是 CPU 缓存一致性协议就能解决
疑惑:CPU 有 MESI 缓存一致性协议——所有核最终都能看到相同的值。为什么还需要「happens-before」规则?
论证:
缓存一致性只保证「最终」——不保证「顺序」。MESI 保证写入最终传播到所有核,但不保证传播顺序。CPU 可以在把
result刷到内存之前就把taskDone刷过去——reporter 看到taskDone=true, result=""在缓存一致性协议下是合法的。编译器重排——编译器在单 goroutine 内部可以任意重排指令(只要不改变as-if-serial语义)。即使 CPU 不乱序执行,编译器也可能生成「先写 taskDone 再写 result」的指令序列。
Store Buffer——CPU 每个核有自己的 store buffer。写入先进入 store buffer,然后异步刷到 cache ——其他核在 store buffer 清空前看不到这个写入。
happens-before 是编程语言层面的「顺序保证」——它告诉程序员:什么顺序一定成立。Go 编译器在生成同步操作(如 channel send)时插入内存屏障(memory barrier),强制 store buffer 清空,保证 happens-before 成立。
结论:硬件缓存一致性解决"最终一致性"问题,happens-before 解决"顺序一致性"问题。前者不保证跨核的指令顺序,后者保证。
# 3. Channel 的 happens-before 规则
# 3.1 send happens-before recv
Go 内存模型的核心规则之一——channel 上的发送 happens-before 对应的接收完成:
var c = make(chan int, 10)
var s string
func sender() {
s = "hello, world" // (1)
c <- 0 // (2)
}
func receiver() {
<-c // (3)
fmt.Println(s) // (4) ← 一定打印 "hello, world"
}
2
3
4
5
6
7
8
9
10
11
12
happens-before 链:
(1) W(s) ───program order─── (2) c <- 0
│
synchronized order (send→recv)
│
(3) <-c ───program order─── (4) R(s)
传递闭包:(1) happens-before (4) → s 的写入对 receiver 可见
2
3
4
5
6
7
有缓冲 channel 同样适用——但注意:send 不是 happens-before 当 buffer 未满时的 send 完成,而是 happens-before recv 完成。如果 buffer 有容量,send 不阻塞,但 recv 仍在 send 之后——happens-before 链仍然成立。
# 3.2 close happens-before 零值接收
var c = make(chan int)
func producer() {
s = "done"
close(c) // (1) close happens-before (2)
}
func consumer() {
<-c // (2) 接收到零值 + ok=false
fmt.Println(s) // (3) 一定打印 "done"
}
2
3
4
5
6
7
8
9
10
11
close(c) 对所有接收者的保证——close(c) happens-before 所有 <-c 返回零值。这表示 close 是一种广播式的同步——一个 goroutine 关闭 channel,所有等待的接收者都能看到关闭前的写入。
# 3.3 无缓冲 channel 的第三条规则
无缓冲 channel 有一个额外的 happens-before 链——recv happens-before send 完成:
var c = make(chan int) // 无缓冲
var s string
func sender() {
s = "hello"
c <- 0 // (2) send 完成 happens-before (1) 但还有 (1) happens-before (2)...
// 实际是: recv 完成 happens-before send 完成
}
func receiver() {
<-c // (1) recv 完成
fmt.Println(s) // 这个能保证看到 s 吗?
}
2
3
4
5
6
7
8
9
10
11
12
13
无缓冲 channel 提供了双向同步:
- send happens-before recv(第 3.1 规则)→ receiver 看到 sender 之前的写入
- recv happens-before send 完成(无缓冲特有)→ sender 执行
c <- 0返回时,receiver 已经收到了
# 4. Mutex 的 happens-before 规则
# 4.1 Unlock happens-before 后续 Lock
var mu sync.Mutex
var s string
func writer() {
s = "hello"
mu.Unlock() // (1) Unlock happens-before (2) Lock
}
func reader() {
mu.Lock() // (2)
fmt.Println(s) // (3) 一定打印 "hello"
}
2
3
4
5
6
7
8
9
10
11
12
重要:Unlock happens-before 后续的 Lock——不是"下一次" Lock。如果有多个 goroutine 等待同一个锁,被唤醒的那个 goroutine 的 Lock 看到的是最后一次 Unlock 之前的写入。
# 4.2 RWMutex 的扩展规则
var rw sync.RWMutex
var s string
func writer() {
s = "hello"
rw.Unlock() // 写者 Unlock
}
func reader1() {
rw.RLock() // 读者 1
fmt.Println(s) // 一定看到 "hello"
rw.RUnlock()
}
func reader2() {
rw.RLock() // 读者 2
fmt.Println(s) // 一定看到 "hello"
rw.RUnlock()
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
RWMutex 的规则:
Unlock(写者释放)happens-before 所有后续的Lock和RLockRUnlock(读者释放)不对后续的RLock建立 happens-before——读者之间没有同步保证。但RUnlock对后续的Lock(写者)有 happens-before(通过写者的 writerSem 信号量隐式同步)。
# 4.3 一个常见的「有锁但仍 data race」案例
var mu sync.Mutex
var data map[string]int // 未初始化!
func init() {
mu.Lock()
data = make(map[string]int)
mu.Unlock()
}
func read() {
// BUG:读 data 前没有 Lock!
fmt.Println(data["key"]) // ← data race——init 的写入对 read 不可见
}
2
3
4
5
6
7
8
9
10
11
12
13
init 中 Unlock 后的锁操作只对后续的 Lock 建立 happens-before。如果 read 不加锁访问 data,没有 happens-before 链——即使另一个 goroutine 正确地用了锁。
# 5. sync/atomic 的内存序
# 5.1 Go 1.19 之前的 atomic.Value vs 之后
Go 1.19 引入了类型化的 sync/atomic 类型(atomic.Int64、atomic.Bool 等),替代了 atomic.Value 和 atomic.AddInt64 等函数式 API:
// Go 1.18 之前
var counter int64
atomic.AddInt64(&counter, 1)
// Go 1.19+
var counter atomic.Int64
counter.Add(1) // 类型安全——不能传错变量
2
3
4
5
6
7
核心改进:类型化 atomic 防止了"把 int32 传给 AddInt64"的误用,同时保持了相同的 happens-before 语义。
# 5.2 atomic 的 happens-before 规则
所有 atomic 操作彼此顺序一致(sequentially consistent)——相当于 C++11 的 memory_order_seq_cst:
var s string
var done atomic.Bool
func writer() {
s = "hello"
done.Store(true) // (1) Store
}
func reader() {
if done.Load() { // (2) Load
fmt.Println(s) // (3) 一定打印 "hello"
}
}
2
3
4
5
6
7
8
9
10
11
12
13
Go 的 atomic 操作保证:
- 单个 atomic 操作是原子的(不会撕裂读写)
- 所有 atomic 操作之间存在全序(total order)——任何两个 atomic 操作,在所有 goroutine 中观察到的顺序一致
- Store happens-before 后续 Load 读取到该 Store 的值
- 非 atomic 变量的写入,如果在 Store 之前,且被 Load 之后读取——可见
Go 不提供 C++ 的 memory_order_relaxed。所有 atomic 操作默认都是 seq_cst。Go 的设计哲学是:要么用 channel/mutex(更高级别的抽象),要么用 atomic(全序保证)——不给「部分保证」的中间地带。
# 5.3 "同步"atomic vs "通讯"atomic 的语义区别
// 用作"同步"(信号)——Load 作为"等待"
var ready atomic.Bool
go func() {
// 准备数据
data = computeData()
ready.Store(true) // 告诉 main:数据准备好了
}()
for !ready.Load() {} // 等待
use(data) // 保证看到 computeData 的结果
// 用作"通讯"(共享计数器)——不需要 Load/Store 之间的 happens-before
var hits atomic.Int64
for i := 0; i < 100; i++ {
go func() { hits.Add(1) }() // 只关心最终计数,不需要立即看到所有写入
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 6. Once 与 WaitGroup 的 happens-before
# 6.1 Once.Do 的初始化保证
var (
once sync.Once
config *Config
)
func GetConfig() *Config {
once.Do(func() {
config = loadConfig() // (1) 初始化
})
return config // (2) 读取
}
2
3
4
5
6
7
8
9
10
11
happens-before 保证:once.Do(f) 内的 f() 执行完成 happens-before 任何 once.Do 的返回。也就是说——第一个调用 Do 的 goroutine 完成 f() 后,所有后续的 Do 调用都保证看到 f() 的结果。
这和 §10 篇分析的 Once 双检锁源码一致——atomic.StoreUint32(&done, 1) 对 atomic.LoadUint32(&done) 建立 happens-before。
# 6.2 WaitGroup.Wait 的同步保证
var wg sync.WaitGroup
var results []string
for i := 0; i < 10; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
results = append(results, fmt.Sprintf("worker_%d", id))
}(i)
}
wg.Wait() // (1) Wait 返回
fmt.Println(results) // (2) 看到所有写入
2
3
4
5
6
7
8
9
10
11
12
13
happens-before 保证:所有 wg.Done() 的调用 happens-before wg.Wait() 返回。所以 Wait 返回后,所有 worker goroutine 中的写入对主 goroutine 可见。
注意:results = append(...) 本身有 data race(多个 goroutine 并发写同一个切片)。这里的 happens-before 只保证 Wait 返回后能看到最终状态,但并发过程中的写操作仍需要额外的同步。
# 7. Data Race 检测原理
# 7.1 vector clock 的运作方式
Go 的 race detector 用 vector clock 追踪每个 goroutine 的"逻辑时间":
vector clock: [G1_time, G2_time, G3_time, ...]
每个 goroutine 维护一个向量:
G1: [1, 0, 0] → G1 的事件发生在 G1 时钟的 tick 1
G2: [0, 1, 0] → G2 的事件发生在 G2 时钟的 tick 1
每次同步操作(Lock/Unlock/channel send)发生时:
合并两个 goroutine 的向量时钟,取 max
检测规则:
A 写入变量 X 时 → 记录 [A_clock, write]
B 读取变量 X 时 → 检查:
if A_clock 不 happened-before B_clock && B 的读不是和 A 的写同步的:
→ report DATA RACE
2
3
4
5
6
7
8
9
10
11
12
13
14
具体例子:
G1: s = "hello" → G1_clock=[1,0], record: X@[1,0]=write
G2: if done.Load() → G2_clock=[0,1], X_clock=[1,0]
G2_clock[0] < A_clock[0]? NO → G2 没有看到 G1 的写入
但 done.Load 是 atomic 操作 → 建立 happens-before → 合并时钟
G2_clock = max([0,1], [1,0]) = [1,1]
G2: fmt.Println(s) → G2_clock=[1,1], X_clock=[1,0]
G2_clock[0] >= A_clock[0]? YES → 可见,不是 data race
2
3
4
5
6
7
# 7.2 ThreadSanitizer 的插桩位置
Go 的 race detector 基于 Google 的 ThreadSanitizer v2(TSan)。它通过编译期插桩实现:
- 每次内存访问前——编译器插入 TSan 的
__tsan_read/__tsan_write回调 - 每次同步操作前后——编译器在
Lock/Unlock/chan send/chan recv前后插入__tsan_acquire/__tsan_release - 每次 goroutine 创建/销毁——插入
__tsan_go_start/__tsan_go_end
性能代价:插桩后的代码慢 5~10 倍,内存增加 5~10 倍。所以 race detector 只用于测试和调试——不能在生产环境常开。
# 7.3 race detector 的局限与 false positive
- 只能检测实际发生的竞争——只有测试覆盖到的代码路径才能检测到 race
- 不能检测「可能但未发生的竞争」——如果竞争窗口只在特定并发交错下才出现,测试可能碰巧没触发
- 资源限制——大量 goroutine 可能导致 vector clock 内存溢出(通常 > 8192 个 goroutine 会被聚合)
# 8. 典型并发陷阱 Top 5
陷阱 1:用 time.Sleep 代替同步
// ❌ 不能保证 happens-before——依赖时间而非同步
go func() { result = compute() }()
time.Sleep(100 * time.Millisecond)
fmt.Println(result) // data race!
2
3
4
陷阱 2:共享 slice 并发 append
// ❌ append 不是原子的——多个 goroutine 同时 append 导致内存覆盖
var results []int
for i := 0; i < 10; i++ {
go func() { results = append(results, i) }()
}
2
3
4
5
陷阱 3:闭包捕获循环变量(Go 1.21 及之前)
// ❌ 所有 goroutine 可能看到同一个 i 值
for i := 0; i < 5; i++ {
go func() { fmt.Println(i) }()
}
// Go 1.22 修复——每次迭代创建新变量
2
3
4
5
陷阱 4:无保护的 map 读写
// ❌ 并发读写 map → fatal error
var m = make(map[string]int)
go func() { for { m["key"] = 1 } }()
go func() { for { _ = m["key"] } }()
2
3
4
陷阱 5:认为 new(T) 返回的对象线程安全
// ❌ sync.Map 保护的是 key-value 映射,不保护 value 内部字段
type Counter struct { n int }
var m sync.Map
m.Store("c", &Counter{})
c, _ := m.Load("c")
c.(*Counter).n++ // ← data race(两个 goroutine 同时 ++)
2
3
4
5
6
# 9. 与 Java / C++11 内存模型对照
| 特性 | Go | Java | C++11 |
|---|---|---|---|
| 同步模型 | happens-before | happens-before | happens-before |
| atomic 内存序 | 仅 seq_cst | relaxed/acquire/release/seq_cst | relaxed/consume/acquire/release/acq_rel/seq_cst |
| volatile | 无对应 | ✅ 保证可见性 | ❌ 不保证 |
| data race 检测 | go run -race (TSan) | 无内置 | ThreadSanitizer (clang) |
| 设计哲学 | 简洁——channel 优先 | 完整——多种选择 | 性能优先——零开销 |
Go 不提供多种 memory_order 的原因——在 Russ Cox 的设计论述中,多种 memory_order 给程序员提供了"性能调优的旋钮",但大多数程序员无法正确使用。Go 选择:要么用 channel/mutex(正确性优先),要么用 atomic(全序保证)——不为少数场景提供复杂的内存序选择。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
回到第 1 章的进度标记——7 个疑问逐条作答:
| 疑问 | 答案 |
|---|---|
| ① happens-before vs MESI? | 第 2.2:MESI 保证最终一致性,happens-before 保证顺序一致性(编译器+CPU 重排) |
| ② channel 的 send→recv? | 第 3.1:send 前的所有写入对 recv 后的所有读取可见 |
| ③ Mutex 的 Unlock→Lock? | 第 4.1:Unlock 前的写入对后续 Lock 后的读取可见 |
| ④ atomic 的内存序? | 第 5.2:全部 seq_cst——Store 前的写入对 Load 后的读取可见 |
| ⑤ Once/WaitGroup? | 第 6 章:Once.Do 内初始化 happens-before 任何返回;Done happens-before Wait |
| ⑥ race detector 原理? | 第 7 章:vector clock 追踪每个 goroutine 的逻辑时间 + TSan 编译期插桩 |
| ⑦ 为什么不提供 memory_order? | 第 9 章:简洁哲学——channel 覆盖 80% 场景,atomic 全序覆盖 20%——不给「部分保证」 |
案例完整修复:
// 修复 1:用 channel 同步
done := make(chan struct{})
go func() {
result = "success"
close(done)
}()
<-done
fmt.Println("result:", result)
// 修复 2:用 atomic.Bool
var taskDone atomic.Bool
go func() {
result = "success"
taskDone.Store(true)
}()
for !taskDone.Load() {}
fmt.Println("result:", result)
// 修复 3:用 sync.Mutex
var mu sync.Mutex
go func() {
result = "success"
mu.Unlock() // 初始化时 Lock 处于加锁状态...
}()
mu.Lock()
fmt.Println("result:", result)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 10.2 一次 happens-before 的完整旅程
goroutine A (writer): goroutine B (reader):
──────────────────────── ──────────────────────
s = "hello"
│ [program order]
▼
ch <- 0
│ [synchronized: send→recv] <-ch
│ │ [program order]
│ ▼
│ fmt.Println(s)
│ ↑
│ │
└──────────────── passes ────────────┘
happens-before 传递闭包:
s = "hello" happens-before fmt.Println(s)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
编译器生成的 happens-before 机制:
ch <- 0 的 runtime 实现(runtime/chan.go):
lock(&ch.lock)
// ... 写入缓冲区或直接传递 ...
unlock(&ch.lock) ← unlock 语义:释放所有之前的写入
<-ch 的 runtime 实现:
lock(&ch.lock) ← lock 语义:获取,此后能看到所有之前释放的写入
// ... 读取数据 ...
unlock(&ch.lock)
2
3
4
5
6
7
8
9
关键:unlock(&ch.lock) 在硬件层面对应写屏障(store-release),lock(&ch.lock) 对应读屏障(load-acquire)。在 x86-64 上,LOCK 前缀指令(如 LOCK CMPXCHG)隐式包含全屏障——自动保证 happens-before。
# 10.3 设计哲学回扣
哲学 1:用 happens-before 代替内存序——Go 的「简化但不弱化」
C++11 的 memory_order 有 6 种——Go 只用 1 种(seq_cst)。这不是功能缺失,是设计选择:channel 和 mutex 覆盖了大多数需要同步的场景,atomic 只用于少量高性能场景。在这些场景中,seq_cst 是唯一正确的选择——如果性能不够,应该用 channel 而不是冒险用 memory_order_relaxed。
哲学 2:同步 = 通信 + 可见性——channel 是两者的统一体
ch <- x 同时做了两件事:把 x 的值传递给接收方(通信),同时建立 happens-before(保证 x 之前的写入可见)。这种统一意味着你不需要单独思考「我怎么让这个变量可见」——channel 自动处理了。
哲学 3:race detector 是开发期工具——不是生产期的安全网
go run -race 只能检测测试中实际触发的竞争。它不能保证「没有竞争」——只能保证「在当前的测试输入和调度交错下没有检测到竞争」。真正的并发安全来自正确的 happens-before 设计——不是来自「race detector 没报」。
哲学 4:happens-before 是编程语言和硬件的共同契约
Go 编译器在生成同步操作时插入内存屏障,CPU 的 MESI 协议保证缓存一致性。但二者的协作有一个前提——程序员正确使用同步原语。如果你用 time.Sleep 代替 channel,编译器不会生成屏障,数据竞争就不是「理论上可能」,而是「生产环境一定会发生」。
# 10.4 速查表
四种同步原语的 happens-before:
| 同步原语 | happens-before 规则 | 简记 |
|---|---|---|
| channel | send happens-before recv | 发在前,收在后 |
| channel(无缓冲) | recv happens-before send 完成 | 收完才发完 |
| close(ch) | close happens-before 零值接收 | 关在前,收在后 |
| Mutex | Unlock happens-before Lock | 放锁在前,拿锁在后 |
| RWMutex | Unlock happens-before Lock/RLock | 写者放锁对所有人可见 |
| atomic | Store happens-before Load 读到该值 | 写在前,读在后 |
| Once | Do(f) 完成 happens-before 任何 Do 返回 | 初始化完成才可用 |
| WaitGroup | Done happens-before Wait 返回 | 全部完成才继续 |
Data Race 的三种要素(三者同时满足):
| 条件 | 示例 |
|---|---|
| 多个 goroutine 并发访问同一内存 | goroutine A 和 B 都访问 x |
| 至少一个操作是写 | x = 1 或 x++ |
| 操作之间没有 happens-before 关系 | 没有 channel/mutex/atomic 同步 |
诊断命令:
# Data race 检测
go run -race main.go
go test -race ./...
go build -race -o app . && ./app
# race detector 选项
GORACE="log_path=/tmp/race strip_path_prefix=/app" go test -race
# 查看 goroutine 栈
curl http://localhost:6060/debug/pprof/goroutine?debug=2
# 严格的编译优化(可能暴露重排导致的 race)
go build -gcflags="-d=ssa/check/on" .
2
3
4
5
6
7
8
9
10
11
12
13
下一篇:我们已经看清了 Go 内存模型的 happens-before 规则和四种同步原语的保证,下一步进入 13.加权信号量与限流——把 semaphore 的加权控制、连接池限流、并发度管理的模式剖开。