Go 内存模型

By The Go Team · Original source ↻

zh en

版本:2022 年 6 月 6 日

引言

Go 内存模型规定了在一个 goroutine 中对变量的读取能够保证观察到另一个不同 goroutine 中对同一变量写入所产生值的条件。

建议

被多个 goroutine 同时访问数据的程序必须对这些访问进行序列化。

要序列化访问,请使用通道操作或其他同步原语(如 syncsync/atomic 包中的原语)来保护数据。

如果你必须阅读本文档的其余部分才能理解程序的行为,那你过于聪明了。

不要耍小聪明。

非正式概述

Go 处理其内存模型的方式与语言的其他部分大致相同,旨在保持语义简单、易懂和有用。本节概述了一般方法,对大多数程序员来说应该足够了。内存模型在下一节中有更正式的规范。

数据竞争data race)定义为对同一内存位置的写入与对该位置的另一次读取或写入并发发生,除非所有涉及的访问都是 sync/atomic 包提供的原子数据访问。如前所述,强烈建议程序员使用适当的同步来避免数据竞争。在没有数据竞争的情况下,Go 程序的行为就像所有 goroutine 都被多路复用到单个处理器上一样。这个属性有时称为 DRF-SC:无数据竞争的程序以顺序一致性方式执行。

虽然程序员应该编写没有数据竞争的 Go 程序,但 Go 实现对于数据竞争的反应是有限制的。实现总是可以通过报告竞争并终止程序来响应数据竞争。否则,对单字大小或亚字大小内存位置的每次读取必须观察到实际写入该位置(可能由并发执行的 goroutine 写入)且尚未被覆盖的值。这些实现限制使得 Go 更像 Java 或 JavaScript,大多数竞争只有有限数量的结果;而不像 C 和 C++,其中任何存在竞争的程序的含义完全未定义,编译器可以做任何事情。Go 的方法旨在使错误程序更可靠、更易于调试,同时仍然坚持竞争是错误,并且工具可以诊断和报告它们。

内存模型

以下 Go 内存模型的正式定义紧密遵循 Hans-J. Boehm 和 Sarita V. Adve 在 PLDI 2008 上发表的"Foundations of the C++ Concurrency Memory Model"中提出的方法。无数据竞争程序的定义以及对无竞争程序的顺序一致性保证与该文中的定义和保证等效。

内存模型描述了程序执行的要求,程序执行由 goroutine 执行组成,而 goroutine 执行又由内存操作组成。

内存操作memory operation)通过四个细节来建模:

  • 其种类,指示它是普通数据读取、普通数据写入,还是同步操作(如原子数据访问、互斥锁操作或通道操作),
  • 其在程序中的位置,
  • 正在访问的内存位置或变量,以及
  • 该操作读取或写入的值。

某些内存操作是读类read-like)操作,包括读取、原子读取、互斥锁锁定和通道接收。其他内存操作是写类write-like)操作,包括写入、原子写入、互斥锁解锁、通道发送和通道关闭。某些操作(如原子比较并交换)既是读类又是写类的。

goroutine 执行goroutine execution)建模为由单个 goroutine 执行的一组内存操作。

要求 1:考虑到从内存读取和写入内存的值,每个 goroutine 中的内存操作必须对应于该 goroutine 的正确顺序执行。该执行必须与顺序先于sequenced before)关系一致,该关系定义为 Go 语言规范中为 Go 的控制流结构以及表达式求值顺序所规定的偏序要求。

Go 程序执行program execution)建模为一组 goroutine 执行,以及一个映射 W,该映射指定每个读类操作从哪个写类操作读取。(同一程序的多次执行可以有不同的程序执行。)

要求 2:对于给定的程序执行,映射 W(当限制在同步操作时)必须能够通过某个隐含的同步操作全序来解释,该全序与顺序以及这些操作读取和写入的值一致。

同步于...之前synchronized before)关系是同步内存操作上的一个偏序,从 W 导出。如果一个同步读类内存操作 r 观察到一个同步写类内存操作 w(即 W(r) = w),那么 w 同步于 r 之前。非正式地说,同步于...之前关系是上一段提到的隐含全序的一个子集,仅限于 W 直接观察到的信息。

先发生于happens before)关系定义为顺序先于关系和同步于...之前关系的并集的传递闭包。

要求 3:对于内存位置 x 上的普通(非同步)数据读取 rW(r) 必须是 r 可见visible)的写入 w,其中可见意味着以下两者都成立:

  1. w 先发生于 r
  2. w 不先发生于任何其他先发生于 r 的(对 x 的)写入 w'

内存位置 x 上的读写数据竞争read-write data race)由 x 上的一个读类内存操作 rx 上的一个写类内存操作 w 组成,其中至少一个是非同步的,并且它们不由先发生于关系排序(即,r 既不先发生于 ww 也不先发生于 r)。

内存位置 x 上的写写数据竞争write-write data race)由 x 上的两个写类内存操作 ww' 组成,其中至少一个是非同步的,并且它们不由先发生于关系排序。

注意,如果内存位置 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。也就是说,每次读取必须观察到由先前或并发写入所写的值。

此外,禁止观察到非因果的和"凭空而来"(out of thin air)的写入。

鼓励(但不要求)对大于单个机器字的内存位置的读取满足与字大小内存位置相同的语义,即观察单个允许的写入 w。出于性能原因,实现可能将较大的操作视为以未指定顺序的一组单个机器字大小的操作。这意味着对多字数据结构的竞争可能导致不一致的值(不对应于单个写入)。当值依赖于内部(指针、长度)或(指针、类型)对的一致性时(在大多数 Go 实现中,接口值、映射、切片和字符串就是这种情况),这种竞争可能进一步导致任意内存损坏。

"不正确的同步"节中给出了不正确同步的示例。

"不正确的编译"节中给出了实现限制的示例。

同步

初始化

程序初始化在单个 goroutine 中运行,但该 goroutine 可能创建其他并发运行的 goroutine。

如果包 p 导入了包 q,则 qinit 函数完成先发生于 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, 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

通道的关闭同步于因通道关闭而返回零值的接收之前。

在前面的示例中,将 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"。(它可能打印空字符串、崩溃或执行其他操作。)

容量为 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.Mutexsync.RWMutex

对于任何 sync.Mutexsync.RWMutex 变量 ln < 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 中)返回之前,而该返回顺序先于 print

对于在 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

这一事实否定了几种常见的惯用法。

双重检查锁定(double-checked locking)试图避免同步开销。例如,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*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 程序员可以依赖这些程序的顺序一致性执行,就像几乎所有其他现代编程语言一样。

当涉及存在竞争的程序时,程序员和编译器都应记住这条建议:不要耍小聪明。