Go happens-before 与同步保证
汇总程序顺序、goroutine 启动、channel、锁、Once 等如何建立 happens-before,并把规范保证与 race detector/错误同步手段区分开。
[!info] 关联笔记
Go happens-before 与同步保证
这个概念为什么会出现
内存模型给出总原则后,工程上仍要一张可查的同步菜单:哪些具体操作会在两个事件之间划上 happens-before(HB)边?
没有这张菜单时,人们会:
- 用
sleep当同步 - 用普通 flag 做“发布”
- 误读 channel 缓冲容量对排序的影响
本篇把规范中最常用的 HB 规则收成可复习清单,并落到可运行例子。
[!abstract] 一句话理解 Happens-before 是跨 goroutine 可见性的偏序;只有内存模型列出的同步事件才能把“我写过”变成“他一定能看见”,程序正文顺序只自动作用于同一 goroutine。
最小可运行示例
先把示例放进业务场景,再看代码:
场景:订单异步计价完成后,查询接口必须读到结果
下单接口把「算价」丢给 worker goroutine,算完后把结果放进 price,再通过 channel 通知 HTTP 查询侧。
查询侧只有在同步点之后读 price,规范才保证看见 42(演示金额),而不是陈旧零值。
本例用无缓冲 channel 做交接:发送完成与接收完成互相建立 HB(握手)。
package main
import "fmt"
// waitOrderPrice 模拟:worker 写好订单价格,handler 同步后再读。
//
// 业务意图:异步计价完成前不返回给查询方;完成后必见最终价。
// 教学点:无缓冲 channel 上「发送完成」与「接收完成」的 HB 链。
func waitOrderPrice() {
var price int
// 无缓冲:发送与接收直接握手(有缓冲时规则更细,见下文)。
done := make(chan struct{})
go func() {
// (1) worker:写共享结果(计价完成)
price = 42
// (2) 发送完成:通知查询侧「可以读了」
done <- struct{}{}
}()
// (3) 接收完成:查询侧通过同步点
<-done
// (4) 程序顺序在 (3) 之后:保证看到 (1) 的写入
fmt.Println(price)
}
func main() {
waitOrderPrice()
}
HB 链(直觉):
(1) 程序顺序 → (2) 发送完成 →(无缓冲 channel)→ (3) 接收完成 程序顺序 → (4)
建议运行:
go run .
期望输出:
42
结合场景再看三个关注点
-
同步点在 channel 交接,不在「我先写了变量」
只有经过 (2)(3),查询接口读price才有规范保证。 -
无缓冲 ≠ 所有 channel 的同一句口诀
有缓冲时发送可能在接收前完成,约束靠容量规则(见下表),别背死「发送一定 HB 接收」。 -
sleep/flag 换不来 HB
计价通知必须用模型列出的同步事件;细节清单见本节后续条目。
核心概念与准确模型
Happens-before 是什么
若事件 (e_1) happens-before (e_2):
- (e_1) 的内存效果对 (e_2) 可见(在模型意义下)
- 关系具有传递性:可串成链
若对同一位置的冲突读写没有 HB 排序 → data race。
1. 程序顺序(intra-goroutine)
同一 goroutine 中,按语言规则排在前面的语句 happens-before 后面的语句。
2. Goroutine 创建
go f() 语句的执行 happens-before f 的开始执行。
var a int
a = 1
go func() {
fmt.Println(a) // 能看到 1(就启动前写入而言)
}()
注意:这不自动同步之后主 goroutine 的后续写。
3. Channel 通信(常用条款)
权威细节以 Go Memory Model 为准。常用直觉:
| 规则(简述) | 工程含义 |
|---|---|
| 无缓冲 channel:发送完成 HB 接收完成 | 交接同步点 |
| 无缓冲:接收完成 HB 发送完成 | 双方握手 |
| 容量为 C:第 k 次接收完成 HB 第 k+C 次发送完成 | 缓冲槽位反压同步 |
| 关闭 HB 因关闭而完成的接收 | close 广播“不会再有值” |
// 缓冲为 1 时,发送可能在接收前完成,但仍有缓冲规则约束后续发送
ch := make(chan int, 1)
4. Mutex / RWMutex
- 对
Unlock的调用 happens-before 后续对同一锁的Lock返回 - RWMutex 有对应的读锁规则(见 mem 文档与 sync 文档)
var mu sync.Mutex
var x int
// g1
mu.Lock()
x = 1
mu.Unlock()
// g2
mu.Lock()
fmt.Println(x) // 若此处 Lock 发生在上述 Unlock 之后,必见 1
mu.Unlock()
5. Once
Once.Do(f):f 的单次执行返回 happens-before 任意 Do 调用返回。适合初始化发布。
6. WaitGroup
Done 与 Wait 的同步保证见 sync.WaitGroup 文档:在计数归零语义下,Wait 返回前可见各 Done 之前的写入(以官方文档表述为准)。
7. atomic
sync/atomic 提供原子性与特定排序语义;用于计数器、标志发布等。复合结构不变量常仍需 Mutex。细节读包文档与内存模型相关段落。
规范 vs 实现 vs 工具
| 层级 | 角色 |
|---|---|
| 规范(mem) | 哪些操作建立 HB;race 定义 |
| 实现 | 如何在机器上插入屏障、调度 channel |
| race detector | 动态发现缺少 HB 的冲突访问;非证明系统 |
边界情况与反直觉行为
1. 有缓冲 channel 不是“自动完整屏障任意时刻”
缓冲使发送方可能在接收方读共享内存之前就返回;若还共享别的内存,需额外同步或遵循缓冲相关 HB 规则仔细推理。
2. select 多就绪
选择哪条 case 是调度/伪随机;每条成功通信仍遵循该通信自己的 HB,但不要假设 case 顺序。
3. 关闭已关闭 channel
panic;与 HB 无关但是工程高频事故(go-channel-ownership-and-closing)。
4. 读锁升级写锁
RWMutex 同 goroutine 持 RLock 再 Lock 易死锁——是锁用法问题,不是 HB 能救。
5. 传递性易漏
只同步了 flag 没把数据写放在 Unlock 前,链会断。
// 错误:数据写在临界区外
data = 1
mu.Unlock() // 若 Lock/Unlock 配对错误更糟
常见误区
[!warning] 常见误区:time.Sleep / 空转算同步 错误:等待“大概写完了”。
正确:channel/锁/WaitGroup/atomic 等。
[!warning] 常见误区:布尔 flag 无原子/无锁发布大对象 错误:
ready=true普通写。
正确:Mutex、atomic、或 channel close 发布。
[!warning] 常见误区:混淆“发生得早”与 happens-before 错误:用日志时间戳当证明。
正确:只认规范边。
[!warning] 常见误区:有 Mutex 就一定无 race 错误:锁不同对象/漏锁读路径。
正确:所有冲突路径同一把锁或等价同步;-race验证。
工程实践
- 优先通信传递所有权,减少共享。
- 共享状态画锁边界:谁在临界区读写哪些字段。
- 初始化用 Once 或 init+不可变快照。
- 关闭 channel 单一所有者。
- 测试:并发表驱动 +
-race+ 适度压力。 - 代码审:搜全局 var、包级 map、无锁自增。
// Once 发布
var (
once sync.Once
client *Client
)
func ClientInstance() *Client {
once.Do(func() {
client = newClient()
})
return client
}
可验证实验
实验 1:无缓冲交接
示例中删掉 <-ch 前的同步,改用 sleep,加 -race。
实验 2:Mutex
两 goroutine 无锁写 x++ 报 race;加锁后消失并检查最终值。
实验 3:close 广播
多个 waiter <-done 在 close(done) 后同时返回,读共享只读快照。
实验 4:错误 flag
普通 bool 发布,race detector 捕获。
本节总结
- HB 是可见性偏序;靠同步事件跨 goroutine 扩展。
- 菜单:程序顺序、go 启动、channel 规则、锁、Once、WaitGroup、atomic…以官方 mem 为准。
- 工具 辅助发现 race,不替代推理。
- 风格:能不共享就不共享;共享就用规范原语。
自测题
概念题
- 为何同一 goroutine 内很少谈内存屏障?
- 无缓冲 channel 的发送完成与接收完成如何互相关?
Unlock与下一次Lock的 HB 方向?
代码推理题
var a, b int
go func() {
a = 1
b = 1
}()
for b == 0 {}
fmt.Println(a)
指出缺失的 HB 与 data race。
工程思考题
Worker pool 完成后要汇总结果。列出两种建立 HB 的方式。
参考答案
展开
- 程序顺序已建立 HB。
- 发送完成与接收完成互相 HB(握手)。
- 某次 Unlock HB 后续成功的 Lock 返回。
代码题:对a/b的并发读无同步 → data race;无 HB 保证见a==1。
工程题:WaitGroup.Wait 后主 goroutine 读入结果;或 workers 向 channel 发送结果由主循环接收(接收 HB 链保证可见)。
延伸阅读与资料来源
| 资料 | 类型 | 支撑 |
|---|---|---|
| The Go Memory Model | 规范向 | HB 全表 |
| Package sync | 标准库 | Mutex/Once/WaitGroup 保证 |
| Package sync/atomic | 标准库 | 原子语义 |
| Share Memory By Communicating | 博客 | 实践风格 |
| Data Race Detector | 工具 | 验证缺失同步 |