[翻译] [The Go Memory Model](https://go.dev/ref/mem)
[翻译] The Go Memory Model
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 string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
调用 hello
将会在某一时刻打印 "hello, world" (有可能在 hello
return 后才打印)
Goroutine destruction (协程的销毁)
Go 内存模型没有保证协程的退出时刻会发生在程序中的某个事件之前 (happens before),例如,在下面的程序中
var a string
func 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 string
func f() {
a = "hello, world"
c <- 0
}
func main() {
go f()
<-c
print(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 string
func f() {
a = "hello, world"
<-c
}
func main() {
go f()
c <- 0
print(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 <- 1
w()
<-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.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
) (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 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 之前完成 (happens before)。结果将会是打印两次 "hello, world"
Incorrect synchronization (错误的同步)
注意读操作 r 可能会观察到与它并发执行的写操作 w (happens concurrently),即使这种情况发生了,也并不能表示发生在 r 之后 (happens after) 的其他读操作可以观察到发生在 w 之前 (happens before) 的其他写操作。
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) 以及忙等待 (busy waiting)。
双重检查锁可以避免同步时的额外开销,例如,下面的 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
变量被更新为 true,也并不能保证 a 变量被更新为 "hello, world" 了。因此上面的程序可能会打印出一个空字符串。
下面是一段忙等待的代码,它的原本目的是:一直等下去,直接 a 被赋值。
var a string
var done bool
func 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 *T
func 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)。