Go 内存模型
2022 年 6 月 6 日版
引言
Go 内存模型规定了在什么条件下,可以保证一个 goroutine 中对某个变量的读取能观察到由另一个 goroutine 对同一变量写入产生的值。
建议
修改同时被多个 goroutine 访问的数据的程序必须序列化此类访问。
为了序列化访问,请使用通道操作或 sync 和 sync/atomic 包中的其他同步原语来保护数据。
如果你必须阅读本文档的其余部分才能理解你的程序行为,那么你太聪明了。
不要耍小聪明。
非正式概述
Go 处理其内存模型的方式与处理语言其他部分的方式大体相同,旨在保持语义的简单、易懂和实用。本节概述了这种方法,对大多数程序员来说应该足够了。内存模型将在下一节中更正式地说明。
数据竞争的定义是:对一个内存位置的写入与对同一位置的另一个读取或写入同时发生,除非所有涉及的访问都是由 sync/atomic 包提供的原子数据访问。正如已经指出的,强烈鼓励程序员使用适当的同步来避免数据竞争。在没有数据竞争的情况下,Go 程序表现得好像所有 goroutine 都多路复用到一个处理器上。这个属性有时被称为 DRF-SC:无数据竞争的程序以顺序一致的方式执行。
虽然程序员应该编写没有数据竞争的 Go 程序,但 Go 实现对数据竞争的响应能力是有限的。实现总是可以通过报告竞争并终止程序来响应数据竞争。否则,对单字大小或子字大小内存位置的每次读取必须观察到实际写入该位置(可能由并发执行的 goroutine 写入)且尚未被覆盖的值。这些实现约束使得 Go 更像 Java 或 JavaScript,因为大多数竞争只有有限数量的结果,而不像 C 和 C++,其中任何有竞争的程序的含义都是完全未定义的,并且编译器可以做任何事情。Go 的方法旨在使错误程序更可靠、更易于调试,同时仍然坚持竞争是错误,并且工具可以诊断和报告它们。
内存模型
以下 Go 内存模型的正式定义紧随 Hans-J. Boehm 和 Sarita V. Adve 在 PLDI 2008 上发表的“C++ 并发内存模型基础”中提出的方法。无数据竞争程序的定义以及无竞争程序的顺序一致性保证与该著作中的定义相同。
内存模型描述了对程序执行的要求,程序执行由 goroutine 执行组成,而 goroutine 执行又由内存操作组成。
一个内存操作由四个细节建模:
- 它的类型,指示它是普通数据读取、普通数据写入,还是同步操作,例如原子数据访问、互斥操作或通道操作,
- 它在程序中的位置,
- 被访问的内存位置或变量,以及
- 操作读取或写入的值。
某些内存操作是读取型的,包括读取、原子读取、互斥锁和通道接收。其他内存操作是写入型的,包括写入、原子写入、互斥解锁、通道发送和通道关闭。有些,例如原子比较并交换,既是读取型又是写入型。
一个goroutine 执行被建模为单个 goroutine 执行的一组内存操作。
要求 1:给定从内存读取和写入的值,每个 goroutine 中的内存操作必须与该 goroutine 的正确顺序执行相对应。该执行必须与在…之前排序关系一致,该关系定义为 Go 语言规范中针对 Go 的控制流结构以及表达式求值顺序所规定的偏序要求。
一个 Go 程序执行被建模为一组 goroutine 执行,以及一个映射 W,它指定每个读取型操作从中读取的写入型操作。(同一程序的多次执行可以有不同的程序执行。)
要求 2:对于给定的程序执行,映射 W,当限于同步操作时,必须可以用同步操作的某个隐式全序来解释,该全序与这些操作的排序以及读取和写入的值一致。
同步之前关系是同步内存操作上的偏序,它源自 W。如果一个同步读取型内存操作 r 观察到一个同步写入型内存操作 w(也就是说,如果 W(r) = w),那么 w 在 r 之前同步。非正式地说,同步之前关系是上一个段落中提到的隐式全序的一个子集,限于 W 直接观察到的信息。
先行发生关系被定义为“在…之前排序”和“同步之前”关系的并集的传递闭包。
要求 3:对于内存位置 x 上的普通(非同步)数据读取 r,W(r) 必须是写入 w,且 w 对 r 是可见的,其中可见意味着以下两点都成立:
- w 在 r 之前发生。
- w 在任何其他在 r 之前发生的写入 w'(到 x)之前不发生。
内存位置 x 上的读写数据竞争由 x 上的一个读取型内存操作 r 和 x 上的一个写入型内存操作 w 组成,其中至少一个是非同步的,并且它们在“先行发生”关系中是无序的(即,既没有 r 先行发生 w,也没有 w 先行发生 r)。
内存位置 x 上的写写数据竞争由 x 上的两个写入型内存操作 w 和 w' 组成,其中至少一个是非同步的,并且它们在“先行发生”关系中是无序的。
请注意,如果内存位置 x 上没有读写或写写数据竞争,那么对 x 的任何读取 r 只有一个可能的 W(r):在“先行发生”顺序中紧接其前的唯一 w。
更一般地,可以证明,任何无数据竞争的 Go 程序,即没有读写或写写数据竞争的程序执行,其结果只能通过 goroutine 执行的某种顺序一致的交错来解释。(证明与上述 Boehm 和 Adve 论文的第 7 节相同。)这个属性被称为 DRF-SC。
正式定义的目的是与 C、C++、Java、JavaScript、Rust 和 Swift 等其他语言为无竞争程序提供的 DRF-SC 保证相匹配。
某些 Go 语言操作,如 goroutine 创建和内存分配,充当同步操作。这些操作对同步前偏序的影响记录在下面的“同步”部分。各个包负责为其自身的操作提供类似的文档。
包含数据竞争的程序的实现限制
上一节给出了无数据竞争程序执行的正式定义。本节非正式地描述了实现必须为包含竞争的程序提供的语义。
任何实现都可以在检测到数据竞争时报告竞争并中止程序执行。使用 ThreadSanitizer(通过“go build -race”访问)的实现正是如此。
对数组、结构体或复数的读取可以实现为对每个单独子值(数组元素、结构体字段或实/虚部)的读取,顺序不限。同样,对数组、结构体或复数的写入可以实现为对每个单独子值的写入,顺序不限。
对存储不大于机器字的值的内存位置 x 的读取 r 必须观察到某个写入 w,使得 r 不在 w 之前发生,并且不存在写入 w' 使得 w 在 w' 之前发生且 w' 在 r 之前发生。也就是说,每次读取都必须观察到由先行发生或并发写入写入的值。
此外,禁止观察非因果和“凭空出现”的写入。
对于大于单个机器字的内存位置的读取,鼓励但不要求其满足与字大小内存位置相同的语义,即观察单个允许的写入 w。出于性能原因,实现可以改为将大型操作视为一组以未指定顺序进行的单个机器字大小操作。这意味着多字数据结构上的竞争可能导致与单个写入不对应的不一致值。当值依赖于内部(指针、长度)或(指针、类型)对的一致性时,如在大多数 Go 实现中接口值、映射、切片和字符串可能出现的情况,此类竞争反过来可能导致任意内存损坏。
不正确同步的示例将在下面的“不正确同步”部分给出。
实现限制的示例将在下面的“不正确编译”部分给出。
同步
初始化
程序初始化在单个 goroutine 中运行,但该 goroutine 可能会创建其他 goroutine,它们并发运行。
如果包 p 导入包 q,则 q 的 init 函数的完成在 p 的任何函数开始之前发生。
所有 init 函数的完成在函数 main.main 开始之前同步。
Goroutine 创建
启动新 goroutine 的 go 语句在 goroutine 执行开始之前同步。
例如,在此程序中
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
调用 hello 将在未来某个时间点打印 "hello, world"(可能在 hello 返回之后)。
Goroutine 销毁
goroutine 的退出不保证在程序中的任何事件之前同步。例如,在此程序中
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}
对 a 的赋值之后没有任何同步事件,因此不保证被任何其他 goroutine 观察到。事实上,一个激进的编译器可能会删除整个 go 语句。
如果一个 goroutine 的效果必须被另一个 goroutine 观察到,请使用锁或通道通信等同步机制来建立相对顺序。
通道通信
通道通信是 goroutine 之间主要的同步方法。对特定通道的每个发送都与来自该通道的相应接收匹配,通常在不同的 goroutine 中。
在通道上的发送在相应接收完成之前同步。
此程序
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world"
c <- 0
}
func main() {
go f()
<-c
print(a)
}
保证打印 "hello, world"。对 a 的写入在对 c 的发送之前排序,该发送在对 c 的相应接收完成之前同步,该接收在 print 之前排序。
通道的关闭在接收到零值(因为通道已关闭)之前同步。
在前面的示例中,用 close(c) 替换 c <- 0 会产生具有相同保证行为的程序。
从无缓冲通道接收在相应发送完成之前同步。
此程序(如上所示,但交换了发送和接收语句并使用了无缓冲通道)
var c = make(chan int)
var a string
func f() {
a = "hello, world"
<-c
}
func main() {
go f()
c <- 0
print(a)
}
也保证打印 "hello, world"。对 a 的写入在对 c 的接收之前排序,该接收在对 c 的相应发送完成之前同步,该发送在 print 之前排序。
如果通道是带缓冲的(例如,c = make(chan int, 1)),那么程序就不保证打印 "hello, world"。(它可能打印空字符串、崩溃或执行其他操作。)
从容量为 C 的通道进行的第 k 次接收在对该通道进行的第 k+C 次发送完成之前同步。
此规则将上一条规则推广到带缓冲的通道。它允许用带缓冲的通道来模拟计数信号量:通道中的项目数对应于活动使用的数量,通道的容量对应于同时使用的最大数量,发送一个项目获取信号量,接收一个项目释放信号量。这是限制并发的常见习语。
此程序为工作列表中的每个条目启动一个 goroutine,但 goroutine 使用 limit 通道进行协调,以确保最多三个 goroutine 同时运行工作函数。
var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func(w func()) {
limit <- 1
w()
<-limit
}(w)
}
select{}
}
锁
sync 包实现了两种锁数据类型:sync.Mutex 和 sync.RWMutex。
对于任何 sync.Mutex 或 sync.RWMutex 变量 l 和 n < m,对 l.Unlock() 的第 n 次调用在对 l.Lock() 的第 m 次调用返回之前同步。
此程序
var l sync.Mutex
var a string
func f() {
a = "hello, world"
l.Unlock()
}
func main() {
l.Lock()
go f()
l.Lock()
print(a)
}
保证打印 "hello, world"。对 l.Unlock() 的第一次调用(在 f 中)在对 l.Lock() 的第二次调用(在 main 中)返回之前同步,该返回在 print 之前排序。
对于对 sync.RWMutex 变量 l 调用 l.RLock,存在一个 n,使得对 l.Unlock 的第 n 次调用在 l.RLock 返回之前同步,并且对 l.RUnlock 的匹配调用在对 l.Lock 的第 n+1 次调用返回之前同步。
成功调用 l.TryLock (或 l.TryRLock) 等同于调用 l.Lock (或 l.RLock)。不成功的调用根本没有同步效果。就内存模型而言,l.TryLock (或 l.TryRLock) 即使在互斥锁 l 未解锁时,也可能返回 false。
Once
sync 包通过使用 Once 类型提供了一种在存在多个 goroutine 的情况下进行安全初始化的机制。多个线程可以为特定的 f 执行 once.Do(f),但只有一个会运行 f(),其他调用会阻塞直到 f() 返回。
once.Do(f) 中对 f() 的单次调用完成在任何 once.Do(f) 调用返回之前同步。
在此程序中
var a string
var once sync.Once
func setup() {
a = "hello, world"
}
func doprint() {
once.Do(setup)
print(a)
}
func twoprint() {
go doprint()
go doprint()
}
调用 twoprint 将只调用 setup 一次。setup 函数将在两次 print 调用之前完成。结果是 "hello, world" 将打印两次。
原子值
sync/atomic 包中的 API 统称为“原子操作”,可用于同步不同 goroutine 的执行。如果原子操作 A 的效果被原子操作 B 观察到,则 A 在 B 之前同步。程序中执行的所有原子操作的行为都好像以某种顺序一致的方式执行。
上述定义与 C++ 的顺序一致原子操作和 Java 的 volatile 变量具有相同的语义。
终结器
runtime 包提供了一个 SetFinalizer 函数,该函数在特定对象不再被程序可访问时添加一个终结器来调用。对 SetFinalizer(x, f) 的调用在终结器调用 f(x) 之前同步。
附加机制
sync 包提供了额外的同步抽象,包括条件变量、无锁映射、分配池和等待组。每个这些抽象的文档都规定了它在同步方面提供的保证。
提供同步抽象的其他包也应记录它们所做的保证。
不正确的同步
带有竞争的程序是不正确的,并且可能表现出非顺序一致的执行。特别地,请注意读取 r 可能会观察到与 r 并发执行的任何写入 w 写入的值。即使发生这种情况,也不意味着在 r 之后发生的读取会观察到在 w 之前发生的写入。
在此程序中
var a, b int
func f() {
a = 1
b = 2
}
func g() {
print(b)
print(a)
}
func main() {
go f()
g()
}
可能发生 g 打印 2 然后打印 0 的情况。
这个事实使得一些常见的惯用法失效。
双重检查锁定是一种避免同步开销的尝试。例如,twoprint 程序可能被错误地编写为
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func doprint() {
if !done {
once.Do(setup)
}
print(a)
}
func twoprint() {
go doprint()
go doprint()
}
但不能保证在 doprint 中,观察到对 done 的写入就意味着观察到对 a 的写入。此版本可能(不正确地)打印空字符串而不是 "hello, world"。
另一个不正确的惯用法是忙等待一个值,如下所示
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func main() {
go setup()
for !done {
}
print(a)
}
如前所述,不能保证在 main 中,观察到对 done 的写入就意味着观察到对 a 的写入,因此此程序也可能打印空字符串。更糟糕的是,不能保证对 done 的写入会被 main 观察到,因为两个线程之间没有同步事件。main 中的循环不保证会结束。
这个主题还有更微妙的变体,例如这个程序。
type T struct {
msg string
}
var g *T
func setup() {
t := new(T)
t.msg = "hello, world"
g = t
}
func main() {
go setup()
for g == nil {
}
print(g.msg)
}
即使 main 观察到 g != nil 并退出其循环,也不能保证它会观察到 g.msg 的初始化值。
在所有这些示例中,解决方案都是相同的:使用显式同步。
不正确的编译
Go 内存模型对编译器优化和 Go 程序本身的限制一样严格。某些在单线程程序中有效的编译器优化在所有 Go 程序中都不有效。特别是,编译器不能引入原始程序中不存在的写入,不能允许单个读取观察多个值,也不能允许单个写入写入多个值。
以下所有示例都假设 `*p` 和 `*q` 指的是多个 goroutine 可访问的内存位置。
不将数据竞争引入无竞争程序意味着不将写入移出它们出现的条件语句。例如,编译器不得在此程序中反转条件
*p = 1
if cond {
*p = 2
}
也就是说,编译器不得将程序重写为这个程序
*p = 2
if !cond {
*p = 1
}
如果 cond 为假并且另一个 goroutine 正在读取 *p,那么在原始程序中,另一个 goroutine 只能观察 *p 的任何先前值和 1。在重写程序中,另一个 goroutine 可以观察到 2,这以前是不可能的。
不引入数据竞争也意味着不假设循环会终止。例如,编译器通常不得将对 *p 或 *q 的访问移到此程序中的循环之前
n := 0
for e := list; e != nil; e = e.next {
n++
}
i := *p
*q = 1
如果 `list` 指向一个循环列表,那么原始程序将永远不会访问 `*p` 或 `*q`,但重写后的程序会。 (如果编译器能够证明 `*p` 不会引发 panic,那么将 `*p` 移到前面是安全的;将 `*q` 移到前面还需要编译器证明没有其他 goroutine 可以访问 `*q`。)
不引入数据竞争还意味着不假设被调用的函数总是返回或没有同步操作。例如,编译器不得将对 *p 或 *q 的访问移到此程序中的函数调用之前(至少在不直接了解 f 的精确行为的情况下)
f() i := *p *q = 1
如果调用从未返回,那么原始程序将再次永远不会访问 `*p` 或 `*q`,但重写后的程序会。而且,如果调用包含同步操作,那么原始程序可以建立在访问 `*p` 和 `*q` 之前的先行发生边,但重写后的程序则不能。
不允许单个读取观察多个值意味着不从共享内存重新加载局部变量。例如,编译器不得在此程序中丢弃 i 并第二次从 *p 重新加载它
i := *p
if i < 0 || i >= len(funcs) {
panic("invalid function index")
}
... complex code ...
// compiler must NOT reload i = *p here
funcs[i]()
如果复杂的代码需要很多寄存器,单线程程序的编译器可以在不保存副本的情况下丢弃 i,然后在 funcs[i]() 之前重新加载 i = *p。Go 编译器不得这样做,因为 *p 的值可能已经改变。(相反,编译器可以将 i 溢出到栈上。)
不允许一次写入写入多个值也意味着在写入之前不使用局部变量将被写入的内存作为临时存储。例如,编译器不得在此程序中使用 *p 作为临时存储。
*p = i + *p/2
也就是说,它不能将程序重写为这个程序
*p /= 2 *p += i
如果 i 和 *p 最初等于 2,则原始代码执行 *p = 3,因此竞争线程只能从 *p 读取 2 或 3。重写后的代码执行 *p = 1 然后 *p = 3,允许竞争线程也读取 1。
请注意,所有这些优化在 C/C++ 编译器中都是允许的:与 C/C++ 编译器共享后端(或作为其后端)的 Go 编译器必须注意禁用对 Go 无效的优化。
请注意,如果编译器可以证明竞争不会影响目标平台上的正确执行,则禁止引入数据竞争的规则不适用。例如,在几乎所有 CPU 上,将
n := 0
for i := 0; i < m; i++ {
n += *shared
}
重写为n := 0
local := *shared
for i := 0; i < m; i++ {
n += local
}
是有效的,前提是可以证明 *shared 在访问时不会发生故障,因为潜在的额外读取不会影响任何现有的并发读取或写入。另一方面,这种重写在源到源转换器中是无效的。
结论
编写无数据竞争程序的 Go 程序员可以依赖这些程序的顺序一致执行,就像几乎所有其他现代编程语言一样。
对于有竞争的程序,程序员和编译器都应牢记建议:不要耍小聪明。