[翻译] The Go Memory Model
[TOC]
Introduction (简介)
Go 内存模型指定了在什么情况下,一个协程对变量的写操作可以被另一个协程读到。
Advice (建议)
当一份数据同时被多个协程访问,在对这份数据进行修改时,需要保证对这份数据的访问时按照一定顺序进行的。
为了让访问有序,需要使用 channel 或者其他同步原语, 在
sync
和
sync/atomic
下面就提供了很多同步原语。
如果你一定要读剩下的内容以便理解你写的程序的行为,那你真是太聪明了。
可太聪明也不是一件好事。
Happens Before
在一个协程中,读写必须按照程序指定的顺序执行。
也就是说,在一个协程中,虽然编译器和处理器可能会对读写顺序重新排序,但是重排序的结果必须不能破坏上面的规定。
因为存在这种重排序机制,一个协程观测到的执行顺序可能和另一个协程不同。例如,如果一个协程执行
a = 1; b = 2;
,另一个协程看到的顺序可能是:先更新 b 为 2,再更新 a 为 1。
我们定义了
happens before
来指定读和写的顺序。
- 如果事件 e1 发生在 e2 之前 (happens before),则描述为 e2 发生在 e1之后 (happens after)
- 如果 e1 既不在 e2 之前发生,也不知 e2 之后发生,则描述为 e1 和 e2 并发发生 (happen concurrenctly)
在一个协程中,happens-before 的顺序就是程序的代码的顺序。
当满足如下条件时,对变量
v
的读操作
r
被允许 (is allowed) 观测到对
r
的写操作
w
被允许观测到并不意味者一定可以观察到?
-
r
不发生在
w
之前 (not happen before)
- 没有其他对
v
的写操作
w\'
,其中
w\'
发生在
w
之后 (happens after) 且发生在
r
之前 (happes before)
为了保证 (guarantee) 读操作
r
可以观测到写操作
w
的结果,需要确保
w
是唯一的写操作。也就是说,当满足下面的要求时,
r
保证可以观测到
w
-
w
发生在
r
之前 (happens before)
- 其他对共享变量
v
的写操作要么发生在
w
之前 (happens before) ,要么发生在
r
之后 (happens after)
这一对条件比上一对条件更严格,它要求没有其他写操作与
w
或
r
同时发生。
在同一个协程内,由于没有并发,所以两条定义是等价的:最近的一条对变量
v
的写操作
w
会被读操作
r
观测到。当有多个协程访问共享变量
v
时,必须使用同步原语来建立
happens-before
条件以保证读操作可以观察到期待的写操作结果。
在内存模型中,初始化一个类型为 t ,值为 0 的变量 v 时,视为一次写操作。
当读写超过一个
machine word
(机器字) 大小的变量时,将会产生多个机器字大小 (totalSize / singleMachineWordSize) 的读写操作,这些操作的顺序是未指定的。
Synchronization (同步)
Initialization (初始化)
程序的初始化操作在一个主协程中执行,这个主协程会创建其他的协程,这些协程并发执行。
如果包
p
导入了另一个包
q
,
q
里面的
init
方法们将会在
p
的
init
方法之前被执行 (happens before)。
main
方法将会在所有的
init
方法执行完之后再执行 (happens after)。
Goroutine creation (协程的诞生)
go
关键字将会开启一个新协程,发生在协程开始执行之前 (happens before) (即在创建协程之后,协程才开始执行)
例如,在这个程序中
var a stringfunc f() {print(a)}func hello() {a = "hello, world"go f()}
调用
hello
将会在某一时刻打印 "hello, world" (有可能在
hello
return 后才打印)
Goroutine destruction (协程的销毁)
Go 内存模型没有保证协程的退出时刻会发生在程序中的某个事件之前 (happens before),例如,在下面的程序中
var a stringfunc hello() {go func() { a = "hello" }()print(a)}
在为 a 赋值后,后面并没有接任何同步原语,所以并不能保证其他协程一定可以看到 a 更新之后的值。事实上,有些激进的编译器甚至可能会直接将
go func()
那一行给优化掉 (delete) 。
如果需要一个协程的结果被其他协程看到,则必须使用同步机制 (例如锁或者 channel 等) 来为这些事件建立一个相对的顺序。
Channel communication (Channel 通信)
Channel 通信是在多个协程间进行同步的最主要方法。同一个
Channel
上的发送和接收是一一对应的,通常发送和接收操作是在不同的协程上进行的。
channel 的发送操作发生在对应的接收操作完成之前 (happens before)
var c = make(chan int, 10)var a stringfunc f() {a = "hello, world"c <- 0}func main() {go f()<-cprint(a)}
上面这个程序保证能输出 "hello world"
- 对 a 的写操作发生在 c 的发送操作之前 (happens before)
- c 的发送操作发生在 c 的接收操作完成之前 (happens before)
- c 的接收操作发生在 print 之前 (happens before)
对 Channel 的 close 操作发生在 Channel 的接收操作之前 (happens before),且由于 Channel 被关闭,接收方将会收到一个零值
在之前的例子中,如果使用
close(c)
来替换
c <- 0
, 读写行为不会发生改变
unbuffered channel 的接收操作发生在发送操作完成之前 (happens before)
下面的程序和之前的差不多,只不过交换了发送和接收语句的位置并使用了一个 unbuffered channel
var c = make(chan int)var a stringfunc f() {a = "hello, world"<-c}func main() {go f()c <- 0print(a)}
这段代码同样能保证最终输出 "hello, world"
- 对 a 的写操作发生在 c 的接收操作之前 (happens before)
- c 的接收操作发生在 c 的发送操作完成之前
- c 的发送操作发生在 print 操作之前
如果 channel 是一个 buffered channel , (例如
c = make(chan int,1)
) , 那就无法保证打印出 "hello, world" 了。(它最终将会输出一个空字符串,crash 或其他未知的事情)
一个容量为 c 的管道上的地 k 个接收操作发生在第 (k + c) 个发送操作之前 (happens before)
这条规则可以视为对上面规则的拓展,(当 c = 0 时就是一个 unbuffered channel 了),可以使用 buffered channel 封装出一个信号量 (semaphore),用 channel 里面的元素数量来代表当前正在使用的资源数量,channel 的容量表示同时可以使用的最大资源数量。当申请信号量时,就往 channel 中发送一个元素,释放信号量时就从 channel 中接收一个元素。
下面的程序为
work
列表中的每个元素都开启了一个协程,并使用名字
limit
的 channel 来协调协程,让同一时刻最多有三个方法在执行
var limit = make(chan int, 3)func main() {for _, w := range work {go func(w func()) {limit <- 1w()<-limit}(w)}select{}}
Locks (锁)
sync
包内实现了两种锁,分别是
sync.Mutex
和
sync.RWMutex
对于类型为
sync.Mutex
或
sync.RWMutex
的变量 l,在 n < m 的情况下,对 l.Unlock() 的第 n 次调用发生在 l.Lock() 的第 m 次调用的返回之前 (happens before)
var l sync.Mutexvar a stringfunc f() {a = "hello, world"l.Unlock()}func main() {l.Lock()go f()l.Lock()print(a)}
上面的代码保证会输出 "hello, world"
- l.Unlock() 的第一次调用 (在
f()
内) 发生在第二次调用
l.lock()
返回之前 (在
main
) (happens before)
- 第二次调用 l.lock() 发生在 print(a) 之前 (happens before)
类型为
sync.RWMutex
的变量 l,对任何一次 l.RLock() 的调用,都会存在一个 n,使得 l.RLock() 发生在第 n 次调用 l.Unlock() 之后,并发生在第 n + 1 次 l.Lock 之前
ps: 换句话说就是一旦拿了写锁,除非写锁释放,否则无法拿到读锁;一旦拿到读锁,除非读锁释放,否则无法拿到读锁。
Once
sync
包内
Once
类型为在多协程场景下的初始化提供了一个安全的机制,当多个线程执行 once.Do(f) 时,只有一个能成功执行 f(),其他线程对 once.Do(f) 的调用会被阻塞住,直到 f() 返回
once.Do(f) 中 f() 将会在所有的 once.Do(f) 返回之前返回 (happens before)
var a stringvar once sync.Oncefunc setup() {a = "hello, world"}func doprint() {once.Do(setup)print(a)}func twoprint() {go doprint()go doprint()}
twoprint
方法仅仅会调用一次
setup
,
setup
将会在 print 之前完成 (happens before)。结果将会是打印两次 "hello, world"
Incorrect synchronization (错误的同步)
注意读操作 r 可能会观察到与它并发执行的写操作 w (happens concurrently),即使这种情况发生了,也并不能表示发生在 r 之后 (happens after) 的其他读操作可以观察到发生在 w 之前 (happens before) 的其他写操作。
var a, b intfunc f() {a = 1b = 2}func g() {print(b)print(a)}func main() {go f()g()}
g() 可能会发生先输出 2 再输出 0 的情况。
这个事实意味着一些常用的技巧可能会失效。例如双重检查锁 (Double-checked locking) 以及忙等待 (busy waiting)。
双重检查锁可以避免同步时的额外开销,例如,下面的
twoprint
程序就可能导致不正确的行为
var a stringvar done boolfunc setup() {a = "hello, world"done = true}func doprint() {if !done {once.Do(setup)}print(a)}func twoprint() {go doprint()go doprint()}
在
doprint
内,即使观察到了
done
变量被更新为 true,也并不能保证 a 变量被更新为 "hello, world" 了。因此上面的程序可能会打印出一个空字符串。
下面是一段忙等待的代码,它的原本目的是:一直等下去,直接 a 被赋值。
var a stringvar done boolfunc setup() {a = "hello, world"done = true}func main() {go setup()for !done {}print(a)}
和上面一样,观察到 done 的写操作并不能表示能观察到对 a 的写操作。所以这段代码也可能会打印出一个空白的字符串。更糟的是,由于不能保证 done 的写操作一定会被 main 观察到,main 里面的 loop 可能永远都不会退出。
还有一个类似的例子,看下面这段代码
type T struct {msg string}var g *Tfunc setup() {t := new(T)t.msg = "hello, world"g = t}func main() {go setup()for g == nil {}print(g.msg)}
即使 main 观察到 g 非空并退出了循环,也不能保证它能看到 g.msg 被初始化之后的结果
上面的这些例子的解决方案都是一样的,那就是显示地使用同步操作 (use explicit synchronization)。