logo

Go内存模型详解

作者
Modified on
Reading time
18 分钟阅读:..评论:..

引言

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直接观察到的信息。

发生前关系定义为按序前和同步前关系的并集的传递闭包。

  • 要求3:对于内存位置x上的普通(非同步)数据读取r,W(r)必须是对r可见的写入w,其中可见意味着以下两点都成立:
  1. w发生在r之前。
  2. w不发生在任何其他发生在r之前的写入w'(对x)之前。

内存位置x上的读写数据竞争由x上的一个类似读取的内存操作r和一个类似写入的内存操作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函数开始之前。

所有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上的发送之前,该发送在相应的接收完成之前同步,而接收又按序在打印之前。

通道的关闭在因为通道关闭而返回零值的接收完成之前同步。

在前面的例子中,用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上完成之前同步,而发送又按序在打印之前。

如果通道是缓冲的(例如,c = make(chan int, 1)),则程序不保证打印"hello, world"。(它可能打印空字符串、崩溃或做其他事情。)

容量为C的通道上的第k次接收在该通道的第k+C次发送完成之前同步。

这条规则将前面的规则推广到缓冲通道。它允许用缓冲通道模拟计数信号量:通道中的项目数对应于活跃使用数,通道的容量对应于最大同时使用数,发送一个项目获取信号量,接收一个项目释放信号量。这是一个常见的限制并发的习惯用法。

这个程序为工作列表中的每个条目启动一个goroutine,但goroutine使用limit通道进行协调,以确保一次最多有三个正在运行工作函数。

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 中)返回之前同步,而第二次调用 l.Lock()又按序在打印之前。

对于 sync.RWMutex 变量 l 上的任何 l.RLock 调用,都存在一个 n,使得第 n 次调用 l.Unlock 在 l.RLock 返回之前同步,而匹配的 l.RUnlock 调用在第 n+1 次调用 l.Lock 返回之前同步。

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 为 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 不会引发 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") } ... 复杂代码 ... // 编译器不能在这里重新加载 i = *p funcs[i]()

如果复杂代码需要许多寄存器,单线程程序的编译器可以丢弃 i 而不保存副本,然后在 funcsi之前重新加载 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 程序员可以依赖这些程序的顺序一致执行,就像在几乎所有其他现代编程语言中一样。

当涉及到有竞争的程序时,程序员和编译器都应该记住这个建议:不要太聪明。

参考

官方:https://go.dev/ref/mem