Go 内存模型
Go 内存模型规定,一个 goroutine 在什么条件下,能够可靠地读到另一个 goroutine 写入的值。它讨论的是共享访问的顺序与可见性;变量分配在堆还是栈、垃圾如何回收,是另外的问题。
并发代码不能只按源码从上往下读。写入与读取可能位于不同 goroutine,单凭“写入大概已经完成”无法判断结果。我们需要找出连接它们的同步关系,才能解释为什么这里可以读取、那里仍然存在竞争。本文以 Go 1.22 的内存模型和标准库契约为准。
1. 面试核心
先记住这几条,再读正文- Go 内存模型关注跨 goroutine 读写的可见性保证。共享内存并不意味着每次普通读取都能得到另一个 goroutine 最近写入的值。卡 · 共享访问与数据竞争
- happens-before 由 goroutine 内的顺序和跨 goroutine 的同步关系传递而成。判断一次读取是否安全,要找到从写入到读取的完整关系,而不是比较日志时间。卡 · 从写入连接到读取
- 两次访问指向同一内存位置、至少一次写入、至少一次不是原子访问,且在 happens-before 上无序,就构成数据竞争。单核执行、暂时没有错误输出,都不能代替同步。
go语句与新 goroutine 开始执行之间有同步保证,goroutine 自然结束却不会自动把结果交给其他 goroutine。读取工作结果需要明确的完成通知或其他同步。卡 · goroutine 与初始化的同步- channel 的同步保证落在特定操作之间。发送与对应接收完成、关闭与因关闭而返回零值的接收,各有明确方向;无缓冲通道的反向保证不能直接套到有缓冲通道上。卡 · 缓冲容量会改变反向保证
sync/atomic的原子操作具有顺序一致性。原子发布一个指针,可以发布此前构造好的对象,但不会自动保护对象之后的修改;每次原子访问安全,也不代表多步业务操作不可分割。卡 · 原子指针与不可变快照- 无数据竞争的程序,其结果可以按保持各 goroutine 顺序的交错执行解释;这仍不保证业务不变量、无死锁或公平性。
-race能发现实际执行路径中的竞争,不能替代同步关系分析。卡 · 共享访问与数据竞争
2. 共享访问与数据竞争
共享访问是多个 goroutine 访问同一份内存。仅仅读同一份已经正确发布、之后不再修改的数据,不会产生读写冲突;一旦其中一个 goroutine 修改它,就需要说明其他访问如何与这次写入协调。
数据竞争(data race)是缺少必要顺序保证的冲突访问。判断时需要同时满足:
- 两次访问指向同一内存位置,例如同一个字段或同一个数组元素。
- 至少一次是写入。
- 至少一次不是
sync/atomic提供的原子访问。 - 两次访问之间没有 happens-before 顺序。
这里的“无序”不是说两条机器指令必须在同一时刻执行。即使设置 GOMAXPROCS(1),不同 goroutine 的访问仍可能缺少语言规定的同步关系。运行时恰好先执行了写入,不等于程序建立了这项保证。
int 很小,也不能据此把普通读写当成同步操作。对一个变量混用原子写入和无保护的普通读取,同样可能产生数据竞争。对切片、字符串、接口等包含多个机器字的值,竞争还可能让读取取得彼此不匹配的组成部分,后果不只是读到旧值。
3. happens-before 关系
happens-before 是内存模型用来判断操作先后保证的关系。它可以沿两类关系传递:
- sequenced-before:同一个 goroutine 中,由语言的控制流和求值规则确定的顺序。
- synchronized-before:同步操作建立的跨 goroutine 关系,例如 channel 的发送与对应接收完成。
如果写入先于某次同步操作,而对应的同步操作又先于读取,就能把这些步骤连起来。它是部分顺序:程序可以只约束有关联的操作,不需要给所有 goroutine 排出唯一的执行次序。
3.1 从写入连接到读取
下面通过关闭 ready 发布 message:
package main
import "fmt"
func main() {
var message string
ready := make(chan struct{})
go func() {
message = "ready"
close(ready)
}()
<-ready
fmt.Println(message)
}
输出 ready。message 的写入在 close(ready) 之前,关闭又与因关闭而返回的接收建立同步关系,接收完成后才读取 message。因此,写入 happens-before 读取。这里没有其他写入,读取能取得这次发布的值。
不要把这一结论扩大成“只要某处用过 channel,所有变量就都安全”。如果把 message 的写入挪到 close(ready) 之后,上面的关系就断了:主 goroutine 的读取和新的写入没有得到排序。
3.2 可见性与执行次序
同步保证约束程序可以观察到的结果,不要求我们追踪每一级 CPU 缓存或每条指令何时执行。编译器与处理器可以在不破坏语言保证的前提下优化程序;业务代码应依据 Go 的同步契约判断结果。
当一个程序没有数据竞争时,其结果可以用保持各 goroutine 内部顺序的某种交错执行解释。这称为 DRF-SC:无数据竞争的程序具有顺序一致性。这里仍然可能存在多种合法交错,不代表所有运行都会产生同一个结果。
这也不等于业务操作整体是原子的。假设两个任务分别执行原子的 Load,都读到 0,然后分别执行原子的 Store(1),最后仍然只有 1。每次访问都可以安全,组合起来却丢失了一次增加。应根据需要使用原子 Add,或者用临界区保护完整操作;锁的对应例子见 Go 互斥锁。
4. goroutine 与初始化的同步
启动 goroutine 可以发布启动前的状态,等待其完成需要另外建立关系。 两个方向不能互相替代。
package main
import "fmt"
func main() {
limit := 8
result := make(chan int)
go func() {
result <- limit
}()
fmt.Println(<-result)
}
输出 8。limit = 8 发生在 go 语句之前,go 语句与新 goroutine 开始执行之间有同步保证,因此工作 goroutine 可以读取这个值。result 用于把结果交回主 goroutine,并让程序等到这次交付。
如果在 go 语句之后继续无保护地修改 limit,就不能用“启动前状态已发布”来保护后续写入。另一方向也一样:工作 goroutine 返回,不会自动让调用者安全读取它留下的普通字段;应通过 channel、锁或 WaitGroup 等建立所需关系。
包初始化同样存在规定的顺序:被导入包的 init 完成先于导入方的 init 开始,全部 init 完成与 main.main 开始之间有同步关系。但在 init 中另起的 goroutine 仍需自己的完成协议,不能把它等同于 init 函数本身。初始化步骤见 Go 包初始化。
5. channel 的同步方向
5.1 发送、接收与关闭
channel 的可见性保证需要按具体事件判断:
| 关系 | 可以据此传递什么 |
|---|---|
| 一次发送先于对应接收完成 | 发送前的写入,可以传递给接收后的读取 |
| 关闭先于因关闭而返回零值的接收 | 关闭前的写入,可以传递给这个接收后的读取 |
| 无缓冲通道的一次接收先于对应发送完成 | 接收前的写入,可以传递给发送后的读取 |
第二条里的“因关闭而返回零值”是必要条件。有缓冲通道关闭后仍可能存有旧元素,读出旧元素时 ok 仍为 true;这次接收对应的是先前的发送,不能仅凭通道已经关闭,就把关闭前的所有写入也当作已发布。持续接收直到观察到 ok == false,才能使用关闭对应的保证。
这些关系也不意味着发送的数据会被深拷贝。发送切片或指针之后仍可能共享底层对象,访问责任见 channel。
5.2 缓冲容量会改变反向保证
无缓冲通道让发送与接收相互等待,因此接收方之前的写入,可以通过交付传递给发送方:
package main
import "fmt"
func main() {
var value int
signal := make(chan struct{})
exited := make(chan struct{})
go func() {
value = 9
<-signal
close(exited)
}()
signal <- struct{}{}
fmt.Println(value)
<-exited
}
输出 9。写入 value 在接收 signal 之前,无缓冲接收又先于对应发送完成,所以发送后的读取能取得 9。最后的 <-exited 用于收尾;value 已经在它之前读取,这个结果依赖的是无缓冲通道的反向同步保证。
若仅把 signal 改成容量为 1 的通道,第一次发送可以直接进入缓冲区,不必等待接收。这时 value 的写入与读取没有得到排序,不能再保证输出 9。最后才等待 exited,也不能修复已经发生的未同步读取。
对容量为 C 的通道,规则是:第 k 次接收先于第 k + C 次发送完成。例如容量为 1 时,第一次接收约束第二次发送完成,而非第一次发送完成。容量为 0 时,正好得到同一次接收与发送完成之间的关系。这个规则也是用有缓冲 channel 表示并发名额的依据。
6. 锁、同步原语与原子发布
6.1 同步对象的契约
标准库也规定了操作之间的同步关系,但每种保证都有具体条件:
| 工具 | 需要检查的关系或条件 | 进一步阅读 |
|---|---|---|
Mutex | 同一把锁的解锁与后续成功 Lock 返回;失败的 TryLock 不建立关系 | Go 互斥锁 |
WaitGroup | Done 与它所解除阻塞的 Wait 返回;登记与复用必须正确 | Go 同步原语 |
Once | 初始化函数结束与该对象的 Do 返回;后续修改不在这次保证内 | Go 同步原语 |
Cond | Signal/Broadcast 与被唤醒的 Wait 返回;条件仍须在锁内循环检查 | Go 同步原语 |
这些同步关系必须连接到真正的冲突访问。例如,写入使用 mu1、读取使用 mu2,不能因为两边都调用了 Lock 就认定已经建立共享状态的保护协议。
6.2 原子指针与不可变快照
sync/atomic 提供的原子操作可以按某个顺序一致的总顺序理解。当原子操作 B 观察到原子操作 A 的效果时,A 与 B 建立同步关系;A 之前的普通写入因此可以发布给 B 之后的读取。
不可变快照是构造完成后不再修改的一份状态。把多个有关联的字段放在同一份快照里,再通过原子指针替换入口,可以让读取方拿到同一个版本:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
type Config struct {
Version int
Limit int
}
func main() {
var current atomic.Pointer[Config]
current.Store(&Config{Version: 1, Limit: 10})
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
current.Store(&Config{Version: 2, Limit: 20})
}()
snapshot := current.Load()
fmt.Println(snapshot.Version, snapshot.Limit)
wg.Wait()
}
输出可以是 1 10 或 2 20。Load 取得其中一个指针,对应对象在发布前已经构造完成,发布后没有再修改;两次字段读取又使用同一个 snapshot,因此不会混成 1 20。
如果每读取一个字段都重新调用 current.Load(),两次调用之间可能发生版本替换,就可能拼出跨版本组合。这仍可能没有数据竞争,却已经违反了“同一个版本”的业务要求。
原子指针只保护指针的访问与发布,不会把所指对象永久变成并发安全对象。发布之后执行 snapshot.Limit = 30,仍可能与其他读取竞争;对象中若有切片、map 或指针,还要保证这些关联对象也不会被其他路径修改。更新时应构造满足不变量的新状态,再替换入口,或者使用适合的锁保护修改。
7. 常见问题与验证
7.1 等一会儿或让出 CPU,能保证读取正确吗?
不能。 time.Sleep 控制当前 goroutine 暂停的时间,runtime.Gosched 允许其他 goroutine 获得调度机会;它们没有给指定共享变量建立写入到读取的同步关系。
等待很久可能让错误暂时难以出现,但依赖的仍是时机。需要等工作结果时,应等待明确的完成事件,并确认结果写入位于完成通知之前。
7.2 race detector 没有报警,就能认为程序正确吗?
不能;没有报警只说明这次执行没有检测到数据竞争。 在支持 race detector 的环境中,可以对 Go 模块执行:
go test -race ./...
报告会给出发生冲突的访问位置及 goroutine 创建位置。修复时应检查它们是否访问同一对象,以及原有同步为什么没有把这两次访问排好顺序。
race detector 只能检查实际执行过的路径。测试没有走到取消、重试、并发更新等分支,就不能据此证明这些分支正确。即使所有访问都通过原子操作或锁完成,仍需另行检查丢失更新、跨字段不变量、死锁和任务能否退出。
审核共享访问时,可以依次问:谁拥有这份状态,哪些路径会修改它,读取依靠哪个同步事件,发布之后还会不会修改。压力运行和竞争检测用于验证这些判断,不能代替对契约的解释。
8 · 面试题卡片
本页题目与参考回答
8.1 Go 的内存模型是什么?
考察重点:共享读写的可见性、同步关系与内存管理的区别。
相关内容:共享访问与数据竞争、happens-before 关系。
参考回答
Go 内存模型规定,一个 goroutine 在什么条件下能够可靠地观察到另一个 goroutine 的写入。 它用 goroutine 内的执行顺序和跨 goroutine 的同步关系构成 happens-before,判断共享访问的先后保证。
共享位置存在至少一次写入、至少一次非原子访问,且两次访问在 happens-before 上无序,就有数据竞争。应使用 channel、锁或正确的原子协议建立所需关系。变量放在堆还是栈、如何垃圾回收,属于内存分配与回收问题。
8.2 为什么关闭 channel 可以发布关闭之前写入的数据?
考察重点:顺序传递、对应同步事件及后续写入的边界。
参考回答
写入先于关闭,关闭先于因关闭而返回零值的接收,接收完成又先于读取,因此写入 happens-before 读取。 在没有其他未同步写入的前提下,这条关系可以发布关闭前的状态。
如果接收拿到的仍是缓冲区旧元素,不能直接使用关闭的保证;如果写入发生在关闭之后,也没有这条发布关系。需要把具体写入和具体接收连接起来。
8.3 goroutine 启动和结束,对共享数据有什么同步保证?
考察重点:启动前发布、结束通知与后续修改。
相关内容:goroutine 与初始化的同步。
参考回答
go 语句与新 goroutine 开始执行之间有同步保证,goroutine 自然结束则不自动与其他事件同步。 启动前写好的状态,可以通过启动关系交给新 goroutine 读取,但启动后的修改仍需要保护。
调用方想读取工作结果时,应通过 channel、正确使用的 WaitGroup 或其他同步机制确认结果已经发布,不能只根据工作函数看起来已经返回来判断。
8.4 为什么把无缓冲 channel 改成有缓冲,可能破坏原来的可见性保证?
考察重点:发送与接收的双向关系、缓冲容量对应的同步距离。
相关内容:缓冲容量会改变反向保证。
参考回答
无缓冲接收先于对应发送完成,有缓冲发送则可能在接收发生前已经完成。 因此,原来通过“接收方先写入、再接收,发送方发送完成后读取”建立的关系,不能直接保留。
容量为 C 时,第 k 次接收先于第 k + C 次发送完成。容量为 1 的第一次接收约束第二次发送完成,而不是第一次;不能只改容量就假定同步协议仍然相同。
8.5 用原子指针发布配置后,配置中的字段就可以任意并发修改吗?
考察重点:原子入口、对象发布、不可变性与一致快照。
相关内容:原子指针与不可变快照。
参考回答
不能。原子指针保护的是入口的原子访问,并通过 Store 与观察到它的 Load 发布此前构造好的对象。 对象发布后的普通字段修改,仍可能与读取产生数据竞争。
可以把对象及其关联数据保持为不可变快照,每次更新构造新对象,再原子替换入口;读取一组关联字段时只加载一次指针。若需要原地修改,就应为实际字段访问建立适当的保护协议。
8.6 单核、Sleep 和 race detector,分别能否证明共享访问正确?
考察重点:调度与同步的区别、动态检测范围、数据竞争与业务竞态。
相关内容:共享访问与数据竞争、等待与让出 CPU、竞争检测的验证范围。
参考回答
三者都不能单独证明共享访问正确。 单核只限制执行资源,不建立 goroutine 之间的 happens-before;Sleep 和 Gosched 只影响调度时机,不发布指定共享变量。
race detector 可以报告实际执行路径中的数据竞争,没有报警不代表所有路径都已验证。即使无数据竞争,也要检查多步操作和业务不变量,例如两次原子 Load 后分别 Store 仍可能丢失更新。
9. 参考资料
- Go 1.22.12:Go 内存模型
- Go 1.22.12:sync/atomic 的同步与顺序一致性契约
- Go 1.22.12:原子类型
- Go 1.22.12:WaitGroup 的同步与复用契约
- Go 1.22.12:Once 的完成发布契约
- Go 1.22.12:Cond 的等待与通知契约
- Go 官方:数据竞争检测器
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明