跳到主要内容
约 12 分钟6 张卡

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)是缺少必要顺序保证的冲突访问。判断时需要同时满足:

  1. 两次访问指向同一内存位置,例如同一个字段或同一个数组元素。
  2. 至少一次是写入。
  3. 至少一次不是 sync/atomic 提供的原子访问。
  4. 两次访问之间没有 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 互斥锁
WaitGroupDone 与它所解除阻塞的 Wait 返回;登记与复用必须正确Go 同步原语
Once初始化函数结束与该对象的 Do 返回;后续修改不在这次保证内Go 同步原语
CondSignal/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 没有报警,就能认为程序正确吗?​

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. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明