Golang 内存模型
摘要
本文介绍了 Golang 的内存模型,在对官方博客 The Go Memory Model 的总结基础上,结合了自己的理解。
结论最先
- Go 的内存模型指明了在何种条件下,一个协程中对某变量的读取可以确保观测到由其他协程对同变量的写入。
- 对于涉及多个 gorouine 读写的共享变量,一定要用
sync
包里的同步操作显式进行同步,避免数据竞争。 - 不要想当然的以为赋值语句是原子的,像字符串、接口等复合结构,不加锁读写可能会导致不一致行为,一定要避免。
- 不要耍小聪明。
简介
内存模型
Go 的内存模型指明了在何种条件下,一个协程中对某变量的读取可以确保观测到由其他协程对同变量的写入。
- 换而言之,不满足这些条件,一个协程的写入就 “不一定” 能够被其他协程观测到。
建议
在程序中,如果要修改被多协程同时访问的共享变量,必须将这些访问串行化。
- 为了将访问串行化,需要使用 channel 或者其他同步原语,参考
sync
与sync/atomic
包。
不要自作聪明地以为对底层了解,搞一些炫技的代码。
数据竞争
数据竞争(data race
)被定义为:对内存地址的写操作与对同一地址的另一读或写操作同时发生。
- 这里提到的读写操作是普通读写,而非由 sync/atomic 包提供的原子数据访问。
如前所述,强烈建议程序员使用适当的同步来避免数据竞争。
在没有数据竞争的情况下,Go 程序的行为就如同所有的 goroutine 都在单个处理器上复用一样。这一特性有时称为 DRF-SC:data-race-free 的程序以顺序一致的方式执行。
检测数据竞争的工具:参考 Data Race Detector。
内存模型的正式定义
内存模型描述了对程序执行的要求。程序执行由多个 goroutine 执行组成,而每个 goroutine 执行又由多个内存操作构成。
Go 中内存模型的定义与 Hans-J. Boehm
和 Sarita V. Adve
在 2008 年发表在 PLDI 上的论文 Foundations of the C++ Concurrency Memory Model 内容非常接近;同时,关于禁止 data-race(data-race-free
)和保证在禁止数据竞争(race-free
)程序中顺序一致性的描述和论文内容完全一致。
内存操作
一个内存操作由四个细节建模:
- 操作类型:表明它是普通数据读取、普通数据写入,还是诸如原子数据访问、互斥锁操作或通道操作之类的同步操作。
- 在程序中的位置。
- 所访问的内存位置或变量。
- 该操作读取或写入的值。
内存操作的类型:
- 类读取操作(
read-like
),包括:读取、原子读取、互斥锁锁定和通道接收。 - 类写入操作(
write-like
),包括:写入、原子写入、互斥锁解锁、通道发送和通道关闭。 - 既类读取操作又类似于写入操作,比如:原子比较并交换。
一个 goroutine 执行被建模为单个 goroutine 执行的一组内存操作。
单 goroutine 内的执行要求: sequenced before
要求 1:在给定内存读写值的情况下,每个 goroutine 中的内存操作必须对应于该 goroutine 的正确顺序执行。此执行必须与 “顺序在前”(sequenced before) 关系保持一致,“顺序在前” 关系定义为 Go 语言规范针对 Go 控制流结构以及表达式求值顺序所设定的偏序要求。
这个还是很容易理解的,单个 goroutine,也就是单线程内,需要遵循程序定义的顺序正确执行,遵循控制流,和表达式求值的顺序,比如前面的先执行、表达式遵循优先级顺序等。
偏序关系 与 全序关系
要注意的是 sequenced before 是偏序关系,而非全序。在数学的序理论中,针对一个集合 X,偏序关系和全序关系均为一种顺序关系,具体定义为:
- 偏序关系(Partially ordered set - Wikipedia):对于某些元素对,其中一个元素排在另一个元素之前,但并非每一对元素都需要具有可比较性。偏序关系满足三个性质:
- 反对称性(Antisymmetry):对于集合中的任意元素 a, b,如果 a ≤ b 且 b ≤ a,则 a = b。即,不存在两个元素互相在彼此之前。
- 传递性(Transitivity):对于集合中的任意元素 a, b, c,如果 a ≤ b 且 b ≤ c,则 a ≤ c。
- 自反性(Reflexivity):对于集合中的任意元素 a,有 a ≤ a
- 全序关系(Total order - Wikipedia):在偏序关系的基础上,任意两个元素均可比较。即在偏序关系的基础上,增加了如下性质:
- 全连接(Total):对于集合中的任意元素 a, b,要么 a ≤ b,要么 b ≤ a
为什么是偏序关系
为什么 sequenced before 是偏序关系,而非全序?个人理解是因为在 Go 中,表达式求值的顺序不是严格固定。比如下面的例子,x := []int{a, f()}
中,a
与 f()
的顺序是不确定的。
1 | a := 1 |
这个观点在 Foundations of the C++ Concurrency Memory Model 中也得到了印证:
The remainder of the C++ standard was modified to define a sequenced-before relation on memory operations performed by a single thread [14]. This is analogous to the program order relation in Java and other work on memory models. Unlike prior work, this is only a partial order per thread, reflecting undefined argument evaluation order.
C++ 标准的其余部分进行了修改,以定义单个线程执行的内存操作之间的 “顺序在前” 关系 [14]。这类似于 Java 中的程序顺序关系以及其他有关内存模型的研究。与先前的研究不同,这只是每个线程内的一种偏序关系,反映了未定义的参数求值顺序。
Go 程序的执行
一个 Go 程序的执行被建模为一组 goroutine 执行以及一个映射 W,该映射 W 指定每个类读取的操作从哪个类写入的操作中读取数据。
同一程序的多次执行可能会有不同的程序执行情况。例如由于调度策略的差异,goroutine 的执行顺序有所差别,进而可能影响到了映射 W 。
同步操作: synchronized before 关系
要求 2:对于给定的程序执行,当映射 W 仅限于同步操作时,必须能够通过同步操作的某种隐式全序来解释,该全序与这些操作的顺序以及它们读写的值保持一致。
“同步在前” ( synchronized before)关系是同步内存操作上的一种偏序关系,由映射 W 推导得出。如果一个类读取的同步内存操作 r 观察到一个类写入的同步内存操作 w(即如果 W (r) = w),那么 w synchronized before r 。通俗地说,“同步在前” 关系是上一段中提到的隐含全序的一个子集,仅限于映射 W 直接观察到的信息。
上面是文档里的定义,我觉得介绍的有点抽象。在论文 Foundations of the C++ Concurrency Memory Model 中,类似定义的名称为 “synchronizes with”,对应这里的 synchronized before 。 论文里的定义为:
- 对于内存位置上的读操作,定义获取操作(acquire)为原子读、原子读写、加锁。
- 对于内存位置上的写操作,定义释放操作(release)为原子写、原子读写、锁释放。
- 如果在同一内存位置上,B 是获取操作,A 是释放操作,那么 A synchronizes with B,并且 W(B) = A 。
这个定义相对好理解一些,即同一内存位置上的类写同步操作 synchronizes with 类读同步操作。
happens before
happens before 关系定义为 “sequenced before” 关系和 “synchronized before” 关系并集的传递闭包。
[!warning] > happens before 并不代表操作发生的实际先后,而是在描述一个操作的结果对另一个操作可见。详见:Happened-before - Wikipedia 即使 A happens before B,B 也可能实际发生在 A 之前。但如果 A 发生在 B 之前,那么 A 的结果一定对 B 可见。
要求 3: 对于一个普通(非同步)的在内存地址 x 上的读取操作 r,W(r) 操作也必须要保证,写入操作 w 对于读取操作 r 是可见的,这里的可见性意味着以下两点均成立:
- 写入操作 w happens before 读取操作 r 之前;
- 不存在其他 happens before r 的写入 w’ (至 x),且写入操作 w 发生在 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。
文档里的介绍有点模糊了普通读和原子读,论文里的介绍会更清晰一点:
- 对于每个普通读 r,W(r) 都对其可见。如果没有数据竞争,只能存在一个可见的 W(r) 。
- 对于每个原子读 r,W(r) 是最后一个发生在这个内存位置的写入。
- 这里的 “发生” 指真正的发生顺序。
更一般地,可以证明,任何无数据竞争的 Go 程序(即其任何程序执行中都不存在读写或写写数据竞争),其结果只能通过 goroutine 执行的某种顺序、一致的交错执行来解释。这个属性称为 DRF - SC(数据竞争自由 - 顺序一致性)。
参考原论文,这里顺序一致性的定义为:
顺序一致性
定义程序的顺序一致性执行为一组线程执行,并伴随一个所有内存操作的全序关系 <T,满足以下约束:
- 每个线程执行都是内部一致的,即它对应于该线程的正确顺序执行,给定从内存读取的值,并且遵守由 “happens before” 关系所隐含的操作顺序。
- <T 与 “happens before” 顺序一致;即,如果操作 a 在操作 b 之前发生,则 a <T b。
- 每个加载、锁定和读 - 改写操作都根据 <T 从同一位置的最后一次写操作中读取值。给定锁的最后一个操作,必须是由同一线程执行的锁操作,且在解锁操作之前。
实际上,这要求 <T 是各个线程操作的交错顺序。
包含数据竞争的程序的实现限制
上一节给出了无数据竞争程序执行的形式化定义。本节将非正式地描述实现对于确实包含数据竞争的程序必须提供的语义。
报告竞争 & 终止程序
Go 提供了线程检查器(通过 “go build -race” 调用)来检测数据竞争,一旦检测到数据竞争,都会报告该竞争并终止程序的执行。
这是排查并发问题的有用工具。
复合结构的读写
对数组、结构体或复数的读取可以按任意顺序实现为对每个单独子值(数组元素、结构体字段或实部 / 虚部)的读取。类似地,对数组、结构体或复数的写入可以按任意顺序实现为对每个单独子值的写入。
单字读取限制
否则,对于一个在读取内存地址为 x 且大小不超过一个机器字长的操作,必须能够观测到在这个读操作之前的一个写操作 w,同时在这个 w 和 r 之间不能插入另外一个写操作 w' 使得 w happens before w',而 w' happens before r !(可见性的定义)
此外,不允许观察到非因果的和 “凭空出现” 的写入。
大于单字的读写限制
对大于单个机器字的内存位置的读取,鼓励但不要求与机器字大小的内存位置具有相同的语义,即观察到单个允许的写入。出于性能原因,实现可能会将较大的操作视为以未指定顺序的一组单个机器字大小的操作。这意味着多字数据结构上的数据竞争可能会导致不一致的值,这些值并不对应于单个写入。当值依赖于内部(指针,长度)或(指针,类型)对的一致性时(在大多数 Go 实现中,接口值、映射、切片和字符串可能就是这种情况),此类数据竞争进而可能导致任意的内存损坏。
同步操作
包的初始化
程序初始化在单个 goroutine 中运行,但该 goroutine 可能会创建其他并发运行的 goroutine。
如果包 p
导入了包 q
,那么 q
的所有 init
函数完成之后,p
的 init
函数才会开始执行。
所有 init
函数完成之后,才会同步启动 main.main
函数。
goroutine 创建
go
语句启动新 goroutine (写),“sequenced before” 于该 goroutine 的开始执行(读)。例如,在这个程序中:
1 | var a string |
调用 hello
将在将来的某个时间点打印 "hello, world"
(可能在 hello
返回之后)。
goroutine 销毁
1 | var a string |
一个 goroutine 的退出,不能保证 “sequenced before” 于程序中的任何事件。例如,在这个程序中:
- 对
a
的赋值之后没有任何同步事件,因此不能保证其他任何 goroutine 能观察到该赋值。实际上,激进的编译器可能会删除整个 go 语句。 - 如果一个 goroutine 的执行结果必须被另一个 goroutine 观察到,就需要使用诸如锁或通道通信这样的同步机制来建立相对顺序。
通道通信
发送和读取
通道通信是 goroutine 之间进行同步的主要方式。对特定通道的每次发送操作,都会与从该通道进行的相应接收操作相匹配,这两个操作通常发生在不同的 goroutine 中。
- 对通道的发送操作(写) synchronized before 在从该通道进行的相应接收操作(读)。
下面这个程序:必定会输出 "hello, world"
。对 a
的写入操作 sequenced before 对 c
的发送操作,而对 c
的发送操作 synchronized before 从 c
进行的相应接收操作,从 c
的接收操作又 sequenced before print
操作。
1 | var c = make(chan int, 10) |
关闭
通道的关闭(写) synchronized before 在因通道关闭而返回零值的接收操作(读)。
在前面的示例中,将 c <- 0 替换为 close (c) 会得到一个具有相同可靠行为的程序。从无缓冲通道接收数据的操作,会在该通道上相应的发送操作完成之前进行同步。
无缓冲通道
这个程序(与上述类似,但交换了发送和接收语句,并使用无缓冲通道):也可以确保该程序会打印 “hello, world”。利用了无缓冲通道中,读写操作会互相阻塞的特性,都就绪了才会往下执行。
- 个人理解:不仅是通道写 synchronized before 通道读,通道读也 synchronized before 通道写。
1 | var c = make(chan int) |
对 a
的写入操作 sequenced before 对 c
的接收操作,对 c
的接收操作 synchronized before 在对 c
相应的发送操作,而对 c
的发送操作 sequenced before 打印操作。
如果通道是带缓冲的(例如,c = make(chan int, 1)
),那么就不能保证该程序会打印 “hello, world”。(它可能会打印空字符串、崩溃或出现其他情况。)
- 个人理解:对于有缓冲通道,不能保证 通道写 synchronized before 通道读。当有缓冲区时,写操作直接写入缓冲区了,无需阻塞等待读。
推广规则
[!tip] 对通道的同步规则推广可得:对容量为
C
的通道进行第k
次接收操作,会在该通道第k+C
次发送操作完成之前同步。
这条规则将前面的规则推广到了带缓冲的通道。它使得可以用带缓冲的通道来模拟计数信号量:通道中的元素数量对应于活动使用的数量,通道的容量对应于同时使用的最大数量,发送一个元素表示获取信号量,接收一个元素表示释放信号量。这是一种限制并发的常用习惯用法。
这个程序为工作列表中的每一项都启动一个 goroutine,但这些 goroutine 使用 limit
通道进行协调,以确保一次最多有三个 goroutine 在运行工作函数。
1 | var limit = make(chan int, 3) |
锁
sync
包实现了两种锁数据类型:sync.Mutex
和 sync.RWMutex
。
对于任意 sync.Mutex
或 sync.RWMutex
变量 l
和 n < m
,l.Unlock()
的第 n
次调用 synchronized before 在 l.Lock()
的第 m
次调用。
- 如果解锁一个未加锁的锁,会报错。
- 如果对一个锁定中的锁加锁,会阻塞等到锁释放。
对于 sync.RWMutex
变量 l
,l.RLock
的任意调用与第 n
次调用 l.Unlock
同步,返回时会保证此关系。此外,与其匹配的 l.RUnlock
调用在第 n+1
次调用 l.Lock
返回之前同步完成。
成功调用 l.TryLock
(或 l.TryRLock
)等效于调用 l.Lock
(或 l.RLock
)。而未成功的调用不具备任何同步效果。就内存模型而言,即使 l
未加锁,l.TryLock
(或 l.TryRLock
)也可能返回 false
。
Once
sync
包通过 Once
类型为多协程提供了安全的初始化机制。多个线程可以调用 once.Do(f)
来执行特定的函数 f
,但 f()
只会运行一次,其它调用会阻塞直到 f()
返回。
完成一次 once.Do(f)
的调用会在其返回之前完成同步。
以下程序:
1 | var a string |
调用 twoprint
会确保 setup
函数被调用且只被调用一次。在任意 print
调用之前,setup
函数会完成。因此,程序会输出两次 "hello, world"。
原子值 (Atomic Values)
sync/atomic
包中的 API 提供了 “原子操作”,可以用于不同协程之间的同步。如果原子操作 A
的效果被另一个原子操作 B
所观察到,则 A
synchronized before B
。在程序中,所有原子操作的执行表现为按某种顺序一致地执行。
上述定义与 C++ 的顺序一致性原子操作和 Java 的 volatile
变量语义一致。
终结器 (Finalizers)
runtime
包提供了一个 SetFinalizer
函数,用于在某对象不再被程序访问时添加一个终结器。对 SetFinalizer(x, f)
的调用,会保证 synchronized before 在调用 f(x)
的终结操作。
附加机制 (Additional Mechanisms)
sync
包还提供了额外的同步抽象,包括条件变量、无锁映射、内存池和等待组。这些的文档会详细说明其同步保证。
其它提供同步抽象的包也应在文档中说明其保证。
不正确的同步
程序中的竞态条件
存在竞态条件的程序是不正确的,可能表现出非顺序一致的执行行为。特别是,请注意一个读取操作 r
可能会读取到与 r
并发执行的某个写入操作 w
的值。即使这种情况发生,也并不意味着 r
之后的读取操作会观察到发生在 w
之前的写入操作。
- 这句话需要着重理解。上文提到,如果没有数据竞态,线程表现为顺序交叉执行,这就意味着如果 r 读取到 w,r 之后的操作一定能读取到 w 之前的操作。
- 但如果有数据竞态,在编译器的重排序侠,这个规则就被打破了,可能会发生预期外的行为。
以下程序:
1 | var a, b int |
可能会输出 2
然后是 0
。因为编译器可能会对代码进行重排序。这一事实使某些常见的代码模式变得无效。
双重检查锁定
双重检查锁定试图避免同步的开销。例如,twoprint
程序可能被错误地写成:
1 | var a string |
但是在 doprint
中,观察到 done
的写入并不保证观察到 a
的写入。该版本可能(错误地)打印空字符串,而不是 "hello, world"。
- 同样,由于重排序,可能先执行
done=true
,再执行 a 的赋值。
忙等待
另一个错误的模式是忙等待值的变化,例如:
1 | var a string |
如前所述,在 main
中,观察到 done
的写入并不保证观察到 a
的写入,因此该程序也可能打印空字符串。更糟糕的是,由于两个线程之间没有同步事件,main
中可能永远无法观察到对 done
的写入。main
中的循环可能永远不会结束。
更微妙的变体
还有一些更微妙的变体,例如以下程序:
1 | type T struct { |
即使 main
观察到 g != nil
并退出循环,也无法保证它会观察到 g.msg
的初始化值。
有可能 setup 被重排序为以下代码。这就回到了那个经典的问题,为什么 Java 里的双重校验加锁单例模式需要用 volatile
修饰,就是为了避免构造函数里的重排序导致读取到未初始化的对象。
1 | func setup() { |
解决方案
在所有这些示例中,解决方案都是相同的:使用显式的同步机制。
不正确的编译
Go 内存模型不仅约束了 Go 程序的行为,也同样约束了编译器的优化。一些在单线程程序中有效的编译器优化在所有 Go 程序中可能是不合法的。具体来说,编译器必须遵守以下规则:
- 不能引入原程序中不存在的写操作。
- 不能允许单次读取观察到多个值。
- 不能允许单次写入写入多个值。
以下示例假设 *p
和 *q
指向的内存地址可以被多个协程访问。
不能在无竞态条件的程序中引入数据竞争
编译器不得将写操作移出其所在的条件语句。例如,编译器不能将以下程序:
1 | *p = 1 |
重写为:
1 | *p = 2 |
在原始程序中,如果 cond
为 false
且另一个协程正在读取 *p
,则该协程只能观察到 *p
的先前值或 1
。在重写后的程序中,另一个协程可能观察到 2
,这是原始程序中不可能发生的。
不能假设循环一定会终止
例如,编译器通常不得将对 *p
或 *q
的访问移到循环之前:
1 | n := 0 |
如果 list
指向一个循环链表,则原始程序永远不会访问 *p
或 *q
,但重写后的程序可能会。(如果编译器能证明访问 *p
不会引发 panic,则移动 *p
是安全的;要移动 *q
,还需要证明没有其他协程可以访问 *q
。)
不能假设被调用的函数总是返回或不包含同步操作
例如,编译器不得将对 *p
或 *q
的访问移到函数调用之前(除非能够直接确定函数 f
的确切行为):
1 | f() |
如果 f
从未返回,则原始程序永远不会访问 *p
或 *q
,但重写后的程序可能会。而且,如果 f
包含同步操作,原始程序可能通过这些同步操作建立对 *p
和 *q
的 “先发生” 关系,但重写后的程序无法保证这一点。
不能允许单次读取观察到多个值
这意味着编译器不得从共享内存中重新加载局部变量。例如,在以下程序中,编译器不得丢弃 i
并第二次从 *p
重新加载:
1 | i := *p |
如果编译器重新从 *p
加载 i
,可能会导致程序观察到不一致的值,这是不允许的。
禁止单次读取观察到多个值
禁止单次读取观察到多个值,意味着编译器不得从共享内存中重新加载局部变量。例如,在以下程序中,编译器不得丢弃 i
并在 funcs[i]()
前重新加载 i = *p
:
1 | i := *p |
在单线程程序中,如果复杂代码需要大量寄存器,编译器可能会丢弃 i
而不保存其副本,并在 funcs[i]()
之前重新加载 i = *p
。但在 Go 中,这种做法是不允许的,因为 *p
的值可能已经改变。(相反,编译器可以将 i
存储到栈中以备后用。)
禁止单次写入写入多个值
这也意味着,编译器不得使用局部变量将要写入的内存作为写入前的临时存储。例如,以下程序:
1 | *p = i + *p/2 |
不得被重写为:
1 | *p /= 2 *p += i |
如果 i
和 *p
初始值均为 2
,原始代码的结果是 *p = 3
,因此发生竞态的线程只能从 *p
读取到 2
或 3
。而重写后的代码会先执行 *p = 1
,然后执行 *p = 3
,使得发生竞态的线程可能读取到 1
,这是原始代码中不可能发生的。
C/C++ 编译器中的优化差异
需要注意的是,所有上述优化在 C/C++ 编译器中是被允许的。因此,共享同一个后端的 Go 编译器需要特别注意,禁用在 Go 中无效的优化。
数据竞争的例外情况
禁止引入数据竞争的规则并非绝对适用。如果编译器能够证明数据竞争不会影响目标平台上的正确执行,则可以进行某些优化。例如,在几乎所有的 CPU 上,以下代码可以被安全地重写:
1 | n := 0 |
重写为(减少对地址的访问):
1 | n := 0 |
前提是能够证明 *shared
的访问不会触发错误,因为潜在增加的读取操作不会影响现有的并发读取或写入。然而,这种重写在源代码到源代码的翻译器中是不合法的,因为它可能改变程序的行为语义。
结论
编写无数据竞争程序的 Go 程序员可以像在几乎所有现代编程语言中一样,依赖这些程序的顺序一致性执行。
对于存在数据竞争的程序,无论是程序员还是编译器,都应该记住一条忠告:不要耍小聪明。