Go语言内存模型
原文链接:The Go Memory Model (Version of June 6, 2022)
引言
Go内存模型规定了在哪些条件下,可以保证一个goroutine对某个变量的读操作能够观察到另一个goroutine对该变量写入的值。
建议
多个goroutine同时访问并修改数据时,必须将这些访问操作序列化。为了将访问操作序列化,应使用通道操作或其他同步原语(例如 sync 和 sync/atomic 包中提供的工具)来保护数据。
如果你的程序行为必须通过阅读本文档的其余部分才能理解,那么你是在过度设计。不要自作聪明。
非正式概述
Go语言处理其内存模型的方式与其整体语言设计思路一致,旨在保持语义简单、易懂且实用。本节提供了方法的总览,对于大多数程序员来说已经足够。下一节将对内存模型进行更正式的规范说明。
数据竞争的定义是:对某个内存位置的写入操作,与对该相同位置进行的另一个读取或写入操作同时发生(除非所有涉及的访问都是通过sync/atomic包提供的原子数据访问)。如前所述,强烈建议程序员使用适当的同步机制来避免数据竞争。在没有数据竞争的情况下,Go程序的行为就像是所有的goroutine都被复用到单个处理器上执行一样。这个特性有时被称为DRF-SC。
关于 DRF-SC
Data-race-free programs execute in a sequentially consistent manner.
大致意思是,无数据竞争的程序以顺序一致的方式执行。简写为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:对于给定的程序执行,当映射 仅限于同步操作时,必须能够通过某种与执行顺序及这些操作读写数值相一致的同步操作隐式全序来解释。
“同步于”关系是同步内存操作上的一个偏序,由 推导得出。如果一个同步的类读取内存操作 观察到一个同步的类写入内存操作 (即 ),那么 同步于 。非正式地说,“同步于”关系是前一段提到的隐含全序的一个子集,仅限于 直接观察到的信息。
“发生于”关系定义为“先后顺序”关系和“同步于”关系之并集的传递闭包。
要求3:对于内存位置 上的一个普通(非同步)数据读取操作 , 必须是一个对 可见的写入操作 ,这里“可见”意味着以下两者同时成立:
- 发生于 。
- 不存在其他对 的写入操作 使得 发生于 并且 发生于 。
内存位置 上的一个读写数据竞争由一个对 的类读取内存操作 和一个对 的类写入内存操作 构成,其中至少有一个是非同步操作,并且它们未被“发生于”关系排序(即 不发生于 且 不发生于 )。
内存位置 上的一个写写数据竞争由两个对 的类写入内存操作 和 构成,其中至少有一个是非同步操作,并且它们未被“发生于”关系排序。
注意,如果内存位置 上没有读写或写写数据竞争,那么任何对 的读取操作 都只有一个可能的 :在“发生于”顺序中紧邻其前的那个单一的写入操作 。
更一般地,可以证明,任何数据竞争无关(即其程序执行中没有读写或写写数据竞争)的Go程序,其所有可能的结果都只能由goroutine执行的某种顺序一致的交错执行来解释。(证明过程与上文引用的Boehm和Adve论文中的第7节相同。)此特性被称为DRF-SC。
此正式定义的目的是与包括C、C++、Java、JavaScript、Rust和Swift在内的其他语言为无竞争程序提供的DRF-SC保证相匹配。
某些Go语言操作,例如goroutine创建和内存分配,充当同步操作。这些操作对“同步于”偏序关系的影响记录在下文的"同步"章节中。各个包应为其自身的操作提供类似的文档说明。
对包含数据竞争的程序的实现限制
上一节给出了无数据竞争程序执行的正式定义。本节非正式地描述了实现必须为确实包含竞争的程序提供的语义。
任何实现在检测到数据竞争时,都可以报告该竞争并停止程序执行。使用 ThreadSanitizer(通过 go build -race 启用)的实现正是这样做的。
对数组、结构体或复数的读取,可以以任意顺序实现为对其每个独立子值(数组元素、结构体字段或实部/虚部)的读取。类似地,对数组、结构体或复数的写入,可以以任意顺序实现为对其每个独立子值的写入。
对于保存的值不大于机器字长的内存位置 的读取操作 ,必须观察到某个写入操作 ,使得 不发生在 之前,并且不存在写入操作 使得 发生在 之前且 发生在 之前。也就是说,每次读取都必须观察到由某个先前或并发的写入操作所写入的值。
此外,不允许观察到违反因果关系的写入和“凭空出现”的写入。
对于大小超过单个机器字长的内存位置的读取,鼓励但不强制要求满足与字长内存位置相同的语义,即观察到单个允许的写入 。出于性能原因,实现可以将更大的操作视为一组以未指定顺序进行的独立机器字长操作。这意味着,多字数据结构上的竞争可能导致出现与单个写入操作不对应的不一致值。当这些值依赖于内部(指针,长度)或(指针,类型)配对的一致性时(在大多数 Go 实现中,接口值、映射、切片和字符串可能属于这种情况),此类竞争进而可能导致任意的内存损坏。
文末的章节中给出了一些不正确的示例:
同步
初始化
程序初始化在单个goroutine中运行,但该goroutine可能会创建其他并发运行的goroutine。
如果一个包 p 导入了包 q,则 q 的 init 函数完成发生于 p 的任何 init 函数开始之前。
所有 init 函数的完成后,才会开始执行 main.main 函数。
Goroutine创建
启动新goroutine的 go 语句执行后,新的goroutine才会开始执行。
例如,在此程序中:
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}调用 hello 将在未来的某个时刻(可能在 hello 函数返回后)打印出 hello, world。
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 语句。
通道的关闭操作在因通道关闭而返回零值的接收操作之前。
在上例中,将 c <- 0 替换为 close(c),得到的程序具有相同的保证行为。
从无缓冲通道的接收操作在该通道上对应发送操作完成之前。
以下程序(与上例类似,但交换了发送和接收语句,并使用无缓冲通道):
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。(它可能打印空字符串、崩溃或执行其他操作。)
从容量为 的通道进行的第 次接收操作在该通道上第 次发送操作的完成之前。
此规则将前一条规则推广至缓冲通道。它允许用缓冲通道来建模计数信号量:通道中的元素数量对应于活动使用的数量,通道的容量对应于同时使用的最大数量,发送一个元素表示获取信号量,接收一个元素表示释放信号量。这是限制并发性的常用模式。
以下程序为工作列表中的每个条目启动一个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,且满足 时,对 l.Unlock() 的第 次调用发生在对 l.Lock() 的第 次调用返回之前。
以下程序:
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 函数中)返回之前,而第二次加锁操作发生在打印语句之前。
对于在 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 处于未锁定状态时,l.TryLock(或 l.TryRLock)也可能返回 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 的执行。如果原子操作 的效果被原子操作 观察到,那么 发生在 之前。程序中执行的所有原子操作,其行为表现得像是在某种顺序一致的顺序中执行的一样。
上述定义与 C++ 的顺序一致原子操作以及 Java 的 volatile 变量具有相同的语义。
终结器
runtime 包提供了 SetFinalizer 函数,用于添加一个终结器,当特定对象不再被程序可达时,该终结器将被调用。对 SetFinalizer(x, f) 的调用发生在终结器调用 f(x) 之前。
其他同步机制
sync 包提供了其他的同步抽象机制,包括条件变量、无锁映射、分配池和等待组。这些机制中,每一项的文档都明确了其在同步方面提供的保证。
其他提供同步抽象机制的包也应对它们所提供的保证进行相应的文档说明。
不正确的同步
存在数据竞争的程序是不正确的,并且可能表现出非顺序一致的执行行为。特别需要注意的是,一个读取操作 可能会观察到任何与 并发执行的写入操作 所写入的值。即使这种情况发生,也并不意味着在 之后发生的读取操作会观察到在 之前发生的写入操作。
在此程序中:
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 的写入,所以这个程序也可能打印空字符串。更糟糕的是,由于两个线程之间没有同步事件,不能保证 main 会观察到对 done 的写入。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 为 false 且另一个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 不会引发恐慌,将 *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")
}
// ... 复杂代码 ...
// 编译器此处不得重新加载 i = *p
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程序员可以依赖这些程序顺序一致的执行,正如几乎所有其他现代编程语言一样。
当涉及存在数据竞争的程序时,程序员和编译器都应牢记这个建议:不要自作聪明。
