Go 内存模型
2022 年 6 月 6 日版本
引言
Go 内存模型指定了在一个 goroutine 中读取变量能够在何时保证观察到由另一个不同 goroutine 对同一变量写入产生的值。
建议
多个 goroutine 同时访问并修改数据的程序必须将此类访问串行化。
为了串行化访问,请使用通道操作或其他同步原语(例如 sync
和 sync/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 发表的“C++ 并发内存模型基础”中提出的方法。无数据竞争程序的定义以及无竞争程序的顺序一致性保证与该著作中的定义和保证等效。
内存模型描述了对程序执行的要求,程序执行由 goroutine 执行组成,而 goroutine 执行又由内存操作组成。
内存操作 通过四个细节来建模
- 其类型,指示是普通数据读取、普通数据写入,还是 同步操作,例如原子数据访问、互斥锁操作或通道操作,
- 它在程序中的位置,
- 被访问的内存位置或变量,以及
- 该操作读取或写入的值。
一些内存操作是 读类操作,包括读取、原子读取、互斥锁加锁和通道接收。其他内存操作是 写类操作,包括写入、原子写入、互斥锁解锁、通道发送和通道关闭。有些操作,例如原子比较并交换,既是读类操作又是写类操作。
goroutine 执行 被建模为单个 goroutine 执行的一组内存操作。
要求 1:给定从内存读取和写入内存的值,每个 goroutine 中的内存操作必须对应于该 goroutine 的正确顺序执行。该执行必须与 sequenced before 关系一致,该关系被定义为 Go 语言规范 对 Go 控制流结构以及 表达式求值顺序 所规定的偏序关系。
Go 程序执行 被建模为一组 goroutine 执行,以及一个映射 W,该映射指定每个读类操作读取来自哪个写类操作。(同一程序的多次执行可以有不同的程序执行。)
要求 2:对于给定的程序执行,当映射 W 仅限于同步操作时,必须可以通过某个隐式的同步操作全序来解释,该全序与顺序性以及这些操作读取和写入的值一致。
synchronized before 关系是同步内存操作上的偏序关系,源自 W。如果同步读类内存操作 r 观察到同步写类内存操作 w(即,如果 W(r) = w),则 w 在 r 之前同步。非正式地说,synchronized before 关系是前一段中提到的隐式全序的子集,仅限于 W 直接观察到的信息。
happens before 关系被定义为 sequenced before 和 synchronized before 关系的联合的传递闭包。
要求 3:对于内存位置 x 上的普通(非同步)数据读取 r,W(r) 必须是对于 r 可见 的写入 w,可见意味着同时满足以下两个条件:
- w happens before r。
- 在任何其他 happens before r 的写入 w'(到 x)之前,w 不会 happens before w'。
内存位置 x 上的 读写数据竞争 包括对 x 的读类内存操作 r 和对 x 的写类内存操作 w,其中至少一个是非同步操作,并且它们在 happens before 关系中是无序的(也就是说,既不是 r happens before w 也不是 w happens before 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 创建和内存分配,充当同步操作。这些操作对 synchronized-before 偏序关系的影响在下面的“同步”部分中有所描述。各个包负责为其自身的操作提供类似的文档。
包含数据竞争的程序的实现限制
前一节提供了无数据竞争程序执行的正式定义。本节非正式地描述了包含竞争的程序必须由实现提供的语义。
任何实现在检测到数据竞争时都可以报告竞争并终止程序执行。使用 ThreadSanitizer(通过“go
build
-race
”访问)的实现正是如此做的。
对数组、结构体或复数的读取可以实现为对每个单独子值(数组元素、结构体字段或实部/虚部)的读取,顺序任意。类似地,对数组、结构体或复数的写入可以实现为对每个单独子值的写入,顺序任意。
对存储不超过机器字大小值的内存位置 x 的读取 r 必须观察到某个写入 w,使得 r 不 happens before w,并且不存在写入 w',使得 w happens before w' 并且 w' happens before r。也就是说,每次读取必须观察到由之前的或并发的写入所写入的值。
此外,不允许观察到因果关系不明确的和“凭空产生”的写入。
鼓励但不要求对大于单个机器字的内存位置的读取满足与字大小内存位置相同的语义,即观察到单个允许的写入 w。出于性能原因,实现可能会将较大操作视为一组以未指定顺序执行的单个机器字大小的操作。这意味着对多字数据结构的竞争可能导致不一致的值,这些值不对应于单个写入。当值依赖于内部(指针、长度)或(指针、类型)对的一致性时(例如在大多数 Go 实现中接口值、映射、切片和字符串的情况),此类竞争可能进而导致任意内存损坏。
不正确的同步示例将在下面的“不正确的同步”部分中给出。
对实现限制的示例将在下面的“不正确的编译”部分中给出。
同步
初始化
程序初始化在单个 goroutine 中运行,但该 goroutine 可以创建其他并发运行的 goroutine。
如果包 p
导入包 q
,则 q
的所有 init
函数的完成 happens before p
的任何函数开始执行。
所有 init
函数的完成 synchronized before 函数 main.main
的开始。
Goroutine 创建
启动新 goroutine 的 go
语句 synchronized before 该 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 中。
对通道的发送 synchronized before 对应的从该通道接收的完成。
这个程序
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
的写入 sequenced before 对 c
的发送,后者 synchronized before 对应的对 c
的接收完成,后者 sequenced before print
。
通道的关闭 synchronized before 因为通道关闭而返回零值的接收。
在前面的例子中,用 close(c)
替换 c <- 0
会得到一个具有相同保证行为的程序。
从无缓冲通道接收 synchronized before 对应的对该通道发送的完成。
这个程序(与上面相同,但发送和接收语句互换并使用无缓冲通道)
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
的写入 sequenced before 对 c
的接收,后者 synchronized before 对应的对 c
的发送完成,后者 sequenced before print
。
如果通道是带缓冲的(例如,c = make(chan int, 1)
),则程序不能保证打印 "hello, world"
。(它可能打印空字符串、崩溃或执行其他操作。)
容量为 C 的通道上的第 k 次接收 synchronized before 从该通道进行的第 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,第 n 次调用 l.Unlock()
synchronized before 第 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
中) synchronized before 对 l.Lock()
的第二次调用(在 main
中)返回,后者 sequenced before print
。
对于对 sync.RWMutex
变量 l
的任何 l.RLock
调用,存在一个 n,使得对 l.Unlock
的第 n 次调用 synchronized before 从 l.RLock
返回,并且对应的 l.RUnlock
调用 synchronized before 对 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()
的单个调用的完成 synchronized before 任何 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 的执行。如果原子操作 B 观察到原子操作 A 的效果,则 A synchronized before B。程序中执行的所有原子操作的行为都像是以某种顺序一致的顺序执行的。
上述定义与 C++ 的顺序一致原子操作和 Java 的 volatile
变量具有相同的语义。
终结器
runtime
包提供了一个 SetFinalizer
函数,该函数为一个特定对象添加一个终结器,当该对象不再被程序可达时调用。对 SetFinalizer(x, f)
的调用 synchronized before 终结化调用 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
不会发生 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]()
之前重新加载 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++ 编译器共享后端(back end)的 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 程序员可以依赖这些程序的顺序一致执行,就像在几乎所有其他现代编程语言中一样。
当涉及到包含竞争的程序时,程序员和编译器都应该记住这条建议:不要耍小聪明。