Go 内存模型

2022 年 6 月 6 日版本

介绍

Go 内存模型指定了在哪些条件下,一个 goroutine 中对变量的读取可以保证观察到另一个 goroutine 对同一变量写入的值。

建议

同时被多个 goroutine 访问的数据修改程序必须序列化此类访问。

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

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

不要耍聪明。

非正式概述

Go 处理其内存模型的方式与处理语言的其他部分非常相似,旨在保持语义简单、易懂且有用。本节概述了这种方法,对于大多数程序员来说应该足够了。下一节将更正式地指定内存模型。

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

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

内存模型

Go 内存模型的以下正式定义紧随 Hans-J. Boehm 和 Sarita V. Adve 在“C++ 并发内存模型的基础”(发表在 PLDI 2008 上)中提出的方法。无数据竞争程序的定义以及对无竞争程序的顺序一致性的保证等同于该工作中的定义。

内存模型描述了对程序执行的要求,程序执行由 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 上的普通(非同步)数据读取 rW(r) 必须是对于 r 可见的写入 w,其中可见表示以下两点都成立

  1. w 发生于 r 之前。
  2. w 不会发生在任何其他写入 w'(到 x)之前,而 w' 发生在 r 之前。

内存位置 x 上的读写数据竞争由内存位置 x 上的类似读取的内存操作 r 和内存位置 x 上的类似写入的内存操作 w 组成,其中至少有一个是非同步的,并且它们不受发生之前关系排序(即,r 既不发生在 w 之前,w 也不发生在 r 之前)。

内存位置 x 上的写写数据竞争由内存位置 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 之前。也就是说,每次读取都必须观察到先前或并发写入写入的值。

此外,不允许观察非因果和“凭空出现”的写入。

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

下面“不正确的同步”部分给出了不正确同步的示例。

下面“不正确的编译”部分给出了实现限制的示例。

同步

初始化

程序初始化在一个 goroutine 中运行,但该 goroutine 可能会创建其他 goroutine,这些 goroutine 并发运行。

如果包p导入了包q,则qinit函数的完成发生在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上的发送之前排序,c上的发送在相应的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上的接收在相应的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变量l以及n < ml.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中)返回之前同步,后者在print之前排序。

对于对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的执行。如果原子操作A的效果被原子操作B观察到,则AB之前同步。程序中执行的所有原子操作的行为就像以某种顺序一致的顺序执行一样。

前面的定义与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的写入,因此此程序也可能打印空字符串。更糟糕的是,无法保证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只能观察*p1的任何先前值。在重写后的程序中,另一个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")
}
... 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 程序员可以依赖这些程序的顺序一致执行,就像在几乎所有其他现代编程语言中一样。

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