引言
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在2008年PLDI会议上发表的《C++并发内存模型的基础》中提出的方法。无数据竞争程序的定义和对无竞争程序的顺序一致性保证与该工作中的定义相同。
内存模型描述了对程序执行的要求,程序执行由goroutine执行组成,goroutine执行又由内存操作组成。
内存操作由四个细节建模:
● 类型,指示它是普通数据读取、普通数据写入还是同步操作(如原子数据访问、互斥操作或通道操作),
● 在程序中的位置,
● 访问的内存位置或变量,
● 操作读取或写入的值。
一些内存操作是读取类型的,包括读取、原子读取、互斥锁和通道接收。其他内存操作是写入类型的,包括写入、原子写入、互斥解锁、通道发送和通道关闭。有些操作(如原子比较和交换)既是读取类型又是写入类型。
goroutine执行被建模为由单个goroutine执行的一组内存操作。
要求1:
每个goroutine中的内存操作必须对应于该goroutine的正确顺序执行,给定从内存读取和写入的值。该执行必须与定义的顺序前关系一致,顺序前关系是由Go语言规范为Go的控制流构造以及表达式的求值顺序设定的部分顺序要求。
Go程序执行被建模为一组goroutine执行,以及一个映射W,指定每个读取操作从其读取的写入操作。(同一程序的多次执行可能有不同的程序执行。)
要求2:
对于给定的程序执行,映射W在限于同步操作时,必须可以通过某种隐式总顺序来解释,该总顺序与同步操作的顺序和这些操作读取和写入的值一致。
同步前关系是从W派生的同步内存操作的部分顺序。如果同步读取类型的内存操作r观察到同步写入类型的内存操作w(即W(r) = w),则w在r之前同步。非正式地说,同步前关系是前面提到的隐含总顺序的一个子集,限于W直接观察到的信息。
happens-before关系被定义为顺序前关系和同步前关系的并集的传递闭包。
要求3:
对于普通(非同步)数据读取r,W(r)必须是可被r观察到的写入w,其中可观察意味着以下两个条件都成立:
● w在r之前发生。
● w不在r之前发生的任何其他写入w'之前发生。
内存位置x上的读写数据竞争由x上的读取类型内存操作r和写入类型内存操作w组成,至少有一个是非同步的,它们在happens-before关系中是无序的(即既不是r在w之前也不是w在r之前)。
内存位置x上的写写数据竞争由x上的两个写入类型内存操作w和w'组成,至少有一个是非同步的,它们在happens-before关系中是无序的。
注意,如果内存位置x上没有读写或写写数据竞争,那么任何对x的读取r只有一个可能的W(r):在happens-before顺序中紧接其之前的单个写入w。
更一般地,可以证明任何无数据竞争的Go程序只能有通过goroutine执行的某种顺序一致的交错来解释的结果。(证明与上述Boehm和Adve论文的第7节相同。)这个属性被称为DRF-SC。
正式定义的目的是匹配其他语言(包括C、C++、Java、JavaScript、Rust和Swift)为无竞争程序提供的DRF-SC保证。
某些Go语言操作(如goroutine创建和内存分配)作为同步操作。这些操作对同步前部分顺序的影响在下面的“同步”部分中有记录。个别包负责为其自己的操作提供类似的文档。
包含数据竞争程序的实现限制
前一节给出了无数据竞争程序执行的正式定义。本节非正式地描述了实现必须为包含竞争的程序提供的语义。
任何实现可以在检测到数据竞争时报告竞争并停止程序执行。使用ThreadSanitizer的实现(通过"go build -race"访问)正是这样做的。
数组、结构体或复数的读取可以被实现为对每个单独子值的读取(数组元素、结构体字段或实数/虚数组件),顺序任意。同样,数组、结构体或复数的写入可以被实现为对每个单独子值的写入,顺序任意。
读取操作r必须观察到某个写入w,使得r不在w之前发生,并且没有其他写入w'使得w在w'之前发生且w'在r之前发生。也就是说,每个读取必须观察到由先前或并发写入的值。
此外,不允许观察到因果无关和“凭空出现”的写入。
鼓励但不要求读取大于单个机器字的内存位置满足与单词大小内存位置相同的语义,观察到单个允许的。出于性能原因,实现可以替代地将较大操作视为未指定顺序的一组单个机器字大小的操作。这意味着多字数据结构上的竞争可能导致不一致的值,不对应于单个写入。当值依赖于内部(指针、长度)或(指针、类型)对的完整性时,如在大多数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上的发送之前有序,该发送在相应的接收完成之前同步,该接收在打印之前有序。
通道的关闭在返回零值的接收之前同步,因为通道已关闭。
在前面的例子中,用close(c)替换c <- 0会产生具有相同保证行为的程序。
无缓冲通道上的接收在相应的发送完成之前同步。
例如,这个程序(如上,但交换了发送和接收语句,并使用无缓冲通道):
var c = make (chan int)
var a string
func f() {
a = "hello, world"
c <- 0
}
func main() {
go f()
<- c
print(a)
}
也保证打印出“hello, world”。对a的写入在c上的接收之前有序,该接收在相应的发送完成之前同步,该发送在打印之前有序。 如果通道是缓冲的(例如,c = make(chan int, 1)),则程序不能保证打印出“hello, world”。(它可能会打印空字符串、崩溃或做其他事情。) 具有容量C的通道上的第k次接收在完成第k+C次发送之前同步。 这条规则将前面的规则推广到缓冲通道。它允许通过缓冲通道模拟计数信号量:通道中的项目数量对应于活动使用的数量,通道的容量对应于最大同时使用的数量,发送一个项目获取信号量,接收一个项目释放信号量。这是限制并发的常见习语。 这个程序为工作列表中的每个条目启动一个goroutine,但goroutine使用限制通道协调以确保最多有三个正在运行工作函数。
锁
sync包实现了两种锁数据类型:sync.Mutex和sync.RWMutex。
对于任何sync.Mutex或sync.RWMutex变量l和n < m,调用n的l.Unlock()在调用m的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中)返回之前同步,该返回在打印之前有序。
对于任何调用l.RLock()的sync.RWMutex变量l,存在一个n,使得第n次调用l.Unlock()在l.RUnlock()返回之前同步,并且匹配的调用l.RLock()在调用n+1的l.Lock()返回之前同步。
成功调用l.TryLock()(或l.TryRL)等同于调用l.Lock()(或l.RLock())。不成功的调用完全没有同步效果。就内存模型而言,l.TryLock()(或l.TryRLock())可以被认为是即使在互斥锁解锁时也能返回false。
Once
sync包通过使用Once类型为多goroutine环境中的初始化提供了安全机制。
Once.Do(f)的单f()的完成在Any call of 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”。
Atomic Values
sync/atomic包中的API统称为“原子操作”,可用于同步不同goroutine的执行。 如果原子操作A的效果被原子操作B观察到,则A在B之前同步。程序中执行操作表现得好像是在某种顺序一致顺序中执行的。
前面的定义与C++的顺序一致原子和Java的volatile变量具有相同的语义。
Finalizers
runtime包提供了一个SetFinalizer函数,当特定对象不再被程序访问时,该函数会添加一个finalizer进行调用。 A call to SetFinalizer(x, f) is synchronized before the finalization call 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访问之前的happens-before边,但重写的程序不会。
不允许单个读取观察到多个值意味着不允许从共享内存重新加载局部变量。例如,编译器不得在这个程序中丢弃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 = 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程序员可以依赖这些程序的顺序一致性执行,就像在几乎所有其他现代编程语言中一样。 当涉及到有竞争的程序时,程序员和编译器都应该记住这个建议:不要自作聪明。