Go 内存模型
15 分钟阅读
The Go Memory Model - Go 内存模型
Version of June 6, 2022
2022年6月6日版本
简介
Go的内存模型指定了
在一个goroutine中读取一个变量时,可以保证观察到在另一个goroutine中对同一变量进行写入产生的值的条件
。
建议
同时被多个goroutine访问的数据的修改程序必须序列化这样的访问。
为了序列化访问,请使用通道操作或其他同步原语,例如sync和sync/atomic包中的原语。
如果您必须阅读本文档的其余部分才能理解程序的行为,那么您正在过于聪明。
不要自作聪明(don’t be clever)
非正式概述
Go以与语言的其余部分类似的方式处理其内存模型,旨在保持语义简单、易于理解和有用。本节对方法进行了一般概述,对大多数程序员来说应该足够了。内存模型在下一节中更正式地指定。
数据竞争被定义为同时发生对同一位置的内存写入和读取或写入,除非所有访问都是由sync/atomic包提供的原子数据访问。正如已经注意到的那样,程序员强烈建议使用适当的同步来避免数据竞争。在没有数据竞争的情况下,Go程序的行为就像所有的goroutines都被多路复用到一个单处理器上一样。这个属性有时被称为DRF-SC:data-race-free程序以顺序一致的方式执行。
尽管程序员应该编写没有数据竞争的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直接观察的信息。
发生之前的关系定义为顺序之前和同步之前关系的联合的传递闭包。
要求3:对于内存位置x上的普通(非同步)数据读取r,W(r)必须是对r可见的写入w,其中可见的意思是以下两个条件都满足:
- w先于r发生。
- w不先于发生在r之前的任何其他写入w’(到x)。
关于内存位置x上的读写数据竞争,由一个读取类的内存操作r和一个写入类的内存操作w组成,其中至少有一个操作不是同步操作,并且这两个操作在happens before中没有顺序(即r既不先于w也不后于w)。
关于内存位置x上的写写数据竞争,由两个写入类的内存操作w和w’组成,其中至少有一个操作不是同步操作,并且这两个操作在happens before中没有顺序。
注意,如果在内存位置x上没有读写或写写数据竞争,则任何读取r在W(r)中只有一个可能性:即在happens before顺序中立即在其之前的单个w。
更普遍地,可以证明,任何没有数据竞争的Go程序,即没有具有读写或写写数据竞争的程序执行,只能由goroutine执行的一些顺序一致地交错来解释结果。 (证明与上述引用的Boehm和Adve论文的第7节相同。)该属性称为DRF-SC。
正式定义的目的是与其他语言(包括C,C ++,Java,JavaScript,Rust和Swift)提供给无竞争程序的DRF-SC保证相匹配。
某些Go语言操作(例如goroutine创建和内存分配)充当同步操作。这些操作对同步之前的部分顺序的影响在下面的"同步"部分中有所记录。各个包负责为其自己的操作提供类似的文档。
含有数据竞争的程序的实现限制
前面的部分给出了数据竞争-free程序执行的形式化定义。本部分非正式地描述了实现必须为包含数据竞争的程序提供的语义。
首先,任何实现都可以在检测到数据竞争时报告竞争并停止程序的执行。使用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的执行开始之前进行同步。
例如,在这个程序中:
|
|
调用hello将在将来的某个时候打印"hello, world”(也许在hello返回之后)。
goroutine 销毁
退出 goroutine 的行为不能保证在程序中的任何事件之前同步。例如,在以下程序中:
|
|
对 a 的赋值没有任何同步事件跟随,因此不能保证被任何其他 goroutine 观察到。实际上,一个激进的编译器可能会删除整个 go 语句。
如果一个 goroutine 的影响必须被另一个 goroutine 观察到,请使用同步机制,如锁或通道通信来建立相对顺序。
通道通信
通道通信是goroutines间同步的主要方法。每次对特定通道的发送操作都匹配到该通道的相应接收操作,通常在不同的协程中执行。
对于特定通道的发送操作在相应接收操作完成之前同步。
下面的程序:
|
|
保证打印出"hello, world"。写操作 a 先于通道 c 的发送操作,而该发送操作在相应接收操作完成之前同步,接着写操作 a 在打印操作之前完成。
当通道关闭时,返回零值的接收操作之前同步通道的关闭。
在上一个示例中,将 c <- 0 替换为 close(c) 将产生相同保证的程序。
从一个非缓冲通道中接收数据,在相应发送操作完成之前进行同步。
下面这个程序(与上面相同,但发送和接收语句交换,使用非缓冲通道):
|
|
同样保证打印出"hello, world"。写操作 a 先于通道 c 的接收操作,而该接收操作在相应发送操作完成之前同步,接着写操作 a 在打印操作之前完成。
如果通道被缓冲(例如 c = make(chan int, 1)),则程序不能保证打印出"hello, world"。(它可能打印出空字符串、崩溃或执行其他操作)。
第 k 次接收操作在具有容量 C 的通道上完成之前同步于第 k+C 次发送操作完成。
此规则将前一个规则推广到缓冲通道。它允许通过缓冲通道建立计数信号量:通道中的项目数对应于活动使用的数量,通道的容量对应于同时使用的最大数量,发送项目获取信号量,接收项目释放信号量。这是限制并发性的常见惯用语。
该程序对于工作列表中的每个条目启动一个协程,但协程使用 limit 通道进行协调,以确保最多有三个工作函数同时运行。
|
|
锁
sync包实现了两种锁数据类型,sync.Mutex和sync.RWMutex。
对于任意sync.Mutex或sync.RWMutex变量l和n < m,调用l.Unlock()的n必须在调用l.Lock()的m返回之前同步。
该程序:
|
|
保证会打印"hello, world"。f中的第一个l.Unlock()调用在main中的第二个l.Lock()调用返回之前同步,而这在打印之前排序。
对于sync.RWMutex变量l上的任何l.RLock调用,都有一个n,使得第n次l.Unlock调用在l.RLock返回之前同步,匹配的l.RUnlock调用在第n + 1次调用l.Lock的返回之前同步。
对sync.Mutex和sync.RWMutex变量调用l.TryLock(或l.TryRLock)的成功调用等效于调用l.Lock(或l.RLock)。失败的调用没有同步效果。就内存模型而言,即使互斥锁l未锁定,l.TryLock(或l.TryRLock)也可以被认为有可能返回false。
Once
sync包提供了Once类型的安全初始化机制,用于处理多个goroutine的存在。多个线程可以执行once.Do(f)来执行特定的f函数,但只有一个将运行f(),其他调用会被阻塞,直到f()返回。
对于once.Do(f)的单个调用的f()的完成,在任何对once.Do(f)的调用返回之前同步。
该程序:
|
|
调用twoprint将仅调用setup一次。setup函数将在任何print调用之前完成。结果将是打印两次"hello, world"。
原子值
sync/atomic包中的API是可以用于同步不同goroutine执行的"原子操作"。如果原子操作A的效果被原子操作B观察到,则A在B之前同步。在程序中执行的所有原子操作的行为好像按某种顺序一致地执行。
上述定义与C ++的顺序一致的原子操作和Java的易失变量具有相同的语义。
Finalizers 终结者
runtime包提供了SetFinalizer函数,该函数会在程序不再使用某个特定对象时调用终结器(finalizer)。对于调用SetFinalizer(x, f)的程序,x的终结器函数f(x)的调用与之前的SetFinalizer调用之间是同步的。
其他机制
sync包提供了额外的同步抽象,包括条件变量、无锁映射、分配池和等待组。每个同步抽象的文档都会说明它所做出的同步保证。
提供同步抽象的其他包也应该说明它们所做出的保证。
错误的同步
带有数据竞争的程序是错误的,可能会导致非顺序一致性执行。特别地,需要注意的是,读取操作r
可能会观察到与r
并发执行的任何写入操作w
写入的值。即使这样,它也不意味着在r
之后发生的读取会观察到w
之前发生的写入。
在下面的程序中:
|
|
函数g可能会打印2,然后再打印0。
这个事实使得一些常见的惯用法变得无效。
Double-checked locking是为了避免同步的开销。例如,twoprint程序可能会写成不正确的形式:
|
|
但是,没有保证在doprint中观察到done的写入意味着观察到a的写入。这个版本可能会(不正确地)打印空字符串而不是"hello, world"。
另一个不正确的习惯是忙等待一个值,如下所示:
|
|
与前面一样,在main中观察到done的写入并不意味着观察到a的写入,因此该程序也可能打印空字符串。更糟糕的是,没有保证done的写入将被main观察到,因为两个线程之间没有同步事件。main中的循环不能保证结束。
还有更微妙的变体,例如下面的程序:
|
|
即使main观察到g != nil
并退出其循环,也不能保证它将观察到g.msg的初始化值。
在所有这些示例中,解决方案都是相同的:使用显式同步。
不正确的编译
Go 内存模型限制编译器优化的程度与限制 Go 程序一样。某些在单线程程序中有效的编译器优化在所有 Go 程序中并不有效。特别地,编译器不能引入原始程序中不存在的写操作,也不能使单个读取操作观察到多个值,并且不能允许单个写入操作写入多个值。
以下所有示例均假定 *p
和 *q
引用对多个 goroutine 可访问的内存位置。
不要在无竞争的程序中引入数据竞争,这意味着不要将写操作移出它们出现在的条件语句中。例如,编译器不应该翻转以下程序中的条件语句:
|
|
也就是说,编译器不应该将程序重写为:
|
|
如果 cond 为 false,并且另一个 goroutine 正在读取 *p
,则在原始程序中,其他 goroutine 只能观察到 *p
的先前任何值和 1。在重写后的程序中,其他 goroutine 可以观察到先前不可能出现的值 2。
不要在无竞争的程序中引入数据竞争,这也意味着不要假设循环一定会终止。例如,在以下程序中,编译器通常不能将对 *p
或 *q
的访问移到循环之前:
|
|
如果 list 指向一个循环链表,则原始程序永远不会访问 *p 或 *q,但重写后的程序会访问。如果编译器可以证明 *p
不会 panic,那么将 *p
移到循环之前是安全的;将 *q
移到循环之前也需要编译器证明没有其他 goroutine 可以访问 *q
。
不要在无竞争的程序中引入数据竞争,这也意味着不要假设被调用的函数总是返回或不包含同步操作。例如,在以下程序中,编译器不应该在函数调用之前移动对 *p
或 *q
的访问(至少不应该没有对 f 的确切行为有直接了解):
|
|
如果该调用永远不返回,则原始程序将不会访问 *p
或 *q
,但是重写后的程序将会访问。如果调用包含同步操作,则原始程序可以建立到 *p
和 *q
访问之前的 happens-before 关系,但重写后的程序则不能。
不允许一个读取操作观察到多个值意味着不从共享内存中重新加载本地变量。例如,在这个程序中,编译器不得丢弃i并在funcs[i]()
之前重新加载i = *p
:
|
|
如果复杂的代码需要许多寄存器,单线程程序的编译器可以在不保存副本的情况下放弃i,然后在funcs [i]()
之前重新加载i = *p
。 Go编译器不允许,因为*p
的值可能已经改变。(相反,编译器可以将i溢出到堆栈。)
不允许单个写入操作写入多个值也意味着不要在写入之前使用本地变量将写入内存用作临时存储。例如,在此程序中,编译器不得在*p
中使用临时存储:
|
|
也就是说,它不得将程序重写为:
|
|
如果i和*p
起初相等于2,则原始代码会执行*p = 3
,因此竞争线程只能从*p
读取2或3。重写代码执行*p = 1
,然后*p = 3
,允许竞争线程也读取1。
请注意,所有这些优化在C / C ++编译器中都是允许的:与C / C ++编译器共享后端的Go编译器必须注意禁用对Go无效的优化。
请注意,如果编译器可以证明竞争不会影响目标平台上的正确执行,则禁止引入数据竞争并不适用。例如,在基本上所有CPU上,将
|
|
转换为:
|
|
只要能够证明*shared
在访问时不会故障,因为潜在的添加读取不会影响任何现有的并发读取或写入。另一方面,转换在源到源转换器中无效。
结论
编写无数据竞争程序的Go程序员可以依赖这些程序的顺序一致的执行,就像其他现代编程语言一样。
在涉及具有竞争关系的程序时,程序员和编译器都应记住这些建议:不要自作聪明(don’t be clever)。