Go的内存模型

Golang 官网有一个单独的页面介绍 —— Go的内存模型。me 这里算是将它翻译一下,然后配几个小程序,再加点(个人)说明。me 表示对某些东西也不是太懂,赶脚有些地方有些模糊,甚至有些奇怪。翻译水平有限,不要骂 me,O__O"…

多线程/并发程序共享数据既是一件幸事,却又是一件麻烦的事。对于共享数据的“读”是没有问题的, 问题就出现在“写”上,比如两个线程写同一个内存中的值该如何是好 ? 高级语言的写一个内存变量比如 a = a+1; 往往不会是一个原子操作(不可分割),也就是该操作会拆分成多步(典型的就是从内存 load 到寄存器、 在寄存器中 +1 、 从寄存器 store 到内存), 试着想想如果两个线程的写的多步交叉起来, 结果会如何 ? 比如 线程 f 和 g 分别将 a = a+1; 按理说, f 和 g 结束之后 a 会增大 2 , 不过可能不会这样。这就是多线程的麻烦的地方之一。 多线程麻烦的地方之二, 试想着两个线程写、一个线程读同一个 a, 那么读的结果该是最初的结果,还是 +1 的结果,还是 +2 的结果?

对于上面的第一个问题的思路就是加锁 lock (或是互斥量 mutex)来保证一致性,保证写的操作是“不可分割”的。对于第二个问题,也就是线程之间执行顺序的控制,一般使用信号量/条件变量等限制它们的先后执行。多线程的同步问题也就是访问的原子性和顺序性问题,好头痛,O__O"… 尼玛,跑题有些严重,=_= 简而言之, 一般多线程对于共享不加处理的话, 数据的值完全是“听天由命”,但是“共享就是共享”, 一个线程在 t 时刻将值 v 写进去了, 后面的所有线程的操作都是在 v 的基础之上进行的。

Go 有些特殊。 go 的多线程,或是说 go 线程,或是说 goroutine,是一件奇怪的事,虽然数据是共享的,然而如果不加同步进制的话,这种共享却是互相之间可能看不到的,O__O"… 举个例子说,共享的 a (int) 变量,go f(); go g(); 如果 main 中创建 f 和 g 线程,都把 a 的值 +1,但是没有任何同步机制,这时候,在 main 中可能看到这种变化 (+1 或是 +2),也可能看不到这种变化 (+0),甚至实现可以是:f 和 g 都没有创建,因为创建它们没有实在的意义,没有同步就可以认为其他线程不关心这种变化,O__O"… 好吧,介是 me 的理解,还是看官方的说法吧。(如果看到这里,会赶脚 go 和其他语言的模型是差不多的呀,实际上看到后面才会发现它们的重大差别。)

版本:2014 年 5 月 31 号

简单说明

这篇 Go 的内存模型是来阐述,在一个 goroutine 中写入的一个变量的值在何种情况下会被另外一个 goroutine 的读所察觉到。

建议

如果多个goroutine同时修改一个数据,那么程序应该序列化这种访问。

序列化访问来保护共享数据可以使用管道,或是其他一些同步原语操作,比如 sync 和 sync/atomic 包的一些。

如果你一定要读下面的内容才能搞懂你的程序,那说明你有些太聪明了。

还是不要太聪明的好,O__O"…

之前发生

在单个的 goroutine 中,读写应该如 (as if) 程序中所指定的顺序一般。但这个说法也暗含:一个编译器或是处理器是可以重新排列变量的读写顺序,只要排列后的顺序没有违反语言规范的定义就可以。因为可能存在着重新排序,所以一个 goroutine 中看到的顺序和另外一个 goroutine 看到的可能是不一样的。比如说,一个 goroutine 中执行 a = 1; b = 2; 另外一个可能看到 b 的值先于 a 的值更新。[ 知道这一点,最后面的几个有 bug 的例子,实际上会好懂很多。]

为了说明读写,我们定义一个之前发生偏序关系 [ 满足自反性、反对称性和传递性的关系叫偏序关系,典型的偏序关系是“小于等于”关系,实际上长辈-晚辈关系更像是偏序关系,因为偏序关系不要求任意两个元素均存在关系 ],这个偏序关系反映 Go 程序中操作的执行情况。如果事件 e1e2 之前发生,那么我们也说,e2e1之后发生。[这里的理解可能有些歧义,me 理解的之前发生是 e1 先于 e2 发生,而 e1 的结束是否先于 e2 的开始,这不知道。]另外的,如果 e1 既不是在 e2之前发生,也不是在 e2之后发生,我们就说,e1 和 e2 是并发的。[也许 u 会奇怪,e1 不是要么先于 e2,不是要么后于 e2 吗?难道还有同时发生一说?貌似也不是这么理解,而是并发的两个线程,没有先于和后于这种关系,偏序关系不要求任意两个量都有关系。]

在单个的 goroutine 中,之前发生的这个顺序关系就是程序所描述的顺序。

只要满足下面两个条件,某一个读 r 访问到另外的一个写 w 的结果都是允许的:["允许",allowed,意味着,u 访问到,但也可以不访问到,O__O"…。]

  • 读 r 不在 w 的之前发生;
  • 没有其他的写 w' 位于在 w 的之后,r 之前;

为了保证一个读 r 的结果正好就是某一个写 w ,需要保证 r 能看到的 w 只有一个;也就是说要满足下面两个条件:

  • w 发生在 r 之前;
  • 其他的写 w' 要么位于 w 之前,要么位于 r 之后;

下面的这对条件要比上面的那对要严格一些,它要求没有其他的写与 w 或是 r 并发(而是要么 w 之前要么 r 之后)。[ 上面的一对条件有多种可能:一种 r 在 w 之后, 另外一种是 r 和 w 并发。]

在单个 goroutine 中是没有并发一说的,所以上面的两对条件代表同样的意思:r 能够看到它之前的离它最近的一次 w 的结果。然而如果有多个 goroutine 访问同一个 v 的时候,我们就必须使用同步事件来建立这种之前发生的关系,让读能看到想看到的写。

v 变量使用 0 值去初始化在内存模型中相当于一次写。

读写一个多字 (word) 的值的时候,相当于多次单字操作,而这个多次操作的顺序是没有指定的。

[赶脚必须多说两句,上面的两种说法,真让人头晕,赶脚有点绕口令的味道,都是用的否定说法,而不是肯定说法,甚是诡异。a 不在 b 的前面,不意味着 a 就在 b 的后面,它们可以是并列的。这样的话,第一种说法,也就是对于两个并列的线程来说,一个线程能否读到另一个线程写的数据,可以有,当然也可以没有。第二种说法,因为 r 就发生在 w 之后,r 前面没有其他的 w',和 r 并列的也没有 w'',所以 r 读到的值必然是 w 写入的值,O__O"…。下面结合图形进行说明。]

单 goroutine 的情形:(标注的 r 为 read,w 为 write,都是对 value 的操作)
-- w0 ---- r1 -- w1 ---- w2 -----  r2 ---- r3 ---------->
这里就不单单是个偏序的关系了,而是一个良序关系,所有的 r/w 的先后都是可以比较的;


双 goroutine 的情形:
-- w0 ---- r1 -- r2 ---- w3 -----  w4 ---- r5 ---------->
-- w1 ----- w2 ---- r3 -----  r4 ---- w5 ---------->
单条线程上都是有先后关系的,然后对两条线程来说,r1 和 w2 的先后顺序是神马?即使“人间时间” w2 > r1,在 go 中,w2 不是先/后于 r1 的,标准说法是两者并发;
并发的 r/w 比如,r3 读的结果是多少呢?可能是前面的 w2 的值,也可能是上面的 w3 的值,或是 w4 的值;而 r5 的值,可能是 w4 的值,也能是 w1、w2、w5 的值;


双 goroutine 的交叉的情形:
-- r0 ---- r1 ------------|-------------- r2 ---------------------|-------------- w5 ---------- r5 ---------->
-- w1 ----- w2 -----------|--------- r3 -----  r4 ---- w4 --------|----------->

现在上面添加了两个交点 —— 使用 | 处,这样的话,r3 就是后于 r1 的,先于 w5 的;
r2 前面的写入的是 w2,而并发的有 w4,所以 r2 的值是不确定的,可以是 w2,也可以是 w4 !!而 r4 前面写入的是 w2,与它并发的没有写入,所以 r4 读的值是 w2 !!

这样的话,关系就貌似有点清晰了,如果不加同步控制的话,所有的线程都是“平行”并发的,这样的话,main 函数以外的线程都是无意义的,因为 main 函数可以认为它们跟 me 没有 关系!!只有加上同步控制,比如锁、比如管道,这样的话,线程之间便打上了“结点”,它们之间便有了先于/后于的顺序,但是在两个“结点”之间的部分,同样还是没有先后关系的。

同步

初始化

程序的初始化是在一单个的 goroutine 中进行的,这个 goroutine 可能会创建其他的 goroutine,它们是并发执行的。

如果一个包 p 导入了包 q,那么 q 的 init 函数完成会在 p 的 init 之前发生。

main.main 函数的开始在所有的 init 函数完成之后发生

goroutine 的创建

go 语句来开始一个新的 goroutine,该语句在 goroutine 的开始执行之前发生

比如,下面的例子:

  1. var a string
  2.  
  3. func f() {
  4.         print(a)
  5. }
  6.  
  7. func hello() {
  8.         a = "hello, world"
  9.         go f()
  10. }

对 hello 的调用会输出 "hello,world",输出可能是在后面的某个时刻,输出的时候也许 hello 已经退出了。

goroutine 的销毁

一个 goroutine 的退出不保证在程序中任何事件的之前发生。比如下面这个程序:

  1. var a string
  2.  
  3. func hello() {
  4.         go func() { a = "hello" }()
  5.         print(a)
  6. }

对 a 的赋值后面没有跟任何的同步事件,所以不保证在其他的 goroutine 中能看到 a 的变化。实际上,一个疯狂的编译器可以删掉整个 go 语句。[ 介也不违反前面的规定。]

如果一个 goroutine 的执行效果想其他的 goroutine 能察觉到,那么请使用同步机制,比如锁或是管道通信,来建立一个相对顺序。

管道通信

管道通信是 goroutine 中进行同步的主要方法。每一个管道的一个发送对应一个对管道的接收,不过通常发送和接收操作是在不同的 goroutine 中。

对于管道的发送操作在对应的接收操作完成的之前发生。

程序:

  1. var c = make(chan int, 10)
  2. var a string
  3.  
  4. func f() {
  5.         a = "hello, world"
  6.         c <- 0
  7. }
  8.  
  9. func main() {
  10.         go f()
  11.         <-c
  12.         print(a)
  13. }

结果是肯定会输出 "hello,world" 的,因为对 a 的写操作发生在对管道 c 的发送数据之前,对管道 c 的发送又发生在对 c 的接收之前,更在 print a 之前。

对于一个管道的关闭操作发生在管道获取 0 值 之前;管道关闭之后,管道接收到一个 0 值。

对于上面的例子,使用 close(c) 更换掉 c <- 0 ,程序的结果是一样的。

对于一个无缓冲的管道来说,接收操作在发送操作完成的之前发生。

下面这个程序,对比上面的交换了发送和接收语句的顺序,不过使用的是无缓冲管道:

  1. var c = make(chan int)
  2. var a string
  3.  
  4. func f() {
  5.         a = "hello, world"
  6.         <-c
  7. }
  8.  
  9. func main() {
  10.         go f()
  11.         c <- 0
  12.         print(a)
  13. }

程序的结果也还是 "hello,world",因为对 a 的写发生在 c 的接收之前,发生在 c 的发送完成之前,又发生在 print 之前。如果这里将管道换成有缓冲管道,比如 c = make(chan int, 1),那么程序的结果就大不一样了,可能输出 "hello,world",可能是空串,也可能是其他一些东西,甚至程序会崩溃掉,O__O"…

对一个容量 n 的管道来说, 第 k 次接收在 n+k 次发送之前发生。

上面这条规则是带缓冲管道的通用规则,它允许我们使用一个缓冲管道来建模一个计数信号量:管道中的 item 数量反映可用的数量,管道的容量对应最大可用数量,向管道发送一个 item 相当于获取一个信号量,接受一个 item 相当于释放一个信号量。这是用来限定并发的一个习惯做法。

下面的程序对于每一个 work 启动一个 goroutine,这些 goroutine 通过一个 limit 管道来保证一次顶多有 3 个运行的 work。

  1. var limit = make(chan int, 3)
  2.  
  3. func main() {
  4.         for _, w := range work {
  5.                 go func() {
  6.                         limit <- 1
  7.                         w()
  8.                         <-limit
  9.                 }()
  10.         }
  11.         select {}
  12. }

sync 包中实现了两个关于锁的数据类型,sync.Mutex 和 sync.RWMutex。[ 互斥锁 mutex 是独占型,只能 lock 一次, unlock 一次,然后才能继续 lock 否则阻塞。 读写互斥锁 reader-writer mutex 是所有的 reader 共享一把锁或是一个 writer 独占一个锁, 如果一个 reader lock 到锁了, 其他的 reader 还可以 lock 但是 writer 不能 lock 。 ]

对于 sync.Mutex 或是 sync.RWMutex 类型的变量 mutex 来说,假定 n < m,对于 mutex.Unlock() 的第 n 次调用在 mutex.Lock() 的第 m 次调用返回之前发生。[ 对于一个 mutex 来说,lock 一下,第二次 lock 会阻塞,只有 unlock 一下才可以继续 lock,就是这个意思。然而 unlock 一个没有 lock 的 mutex 会怎么样呢?error ! ]

程序:

  1. var a string
  2.  
  3. func f() {
  4.         a = "hello, world"
  5.         l.Unlock()
  6. }
  7.  
  8. func main() {
  9.         l.Lock()
  10.         go f()
  11.         l.Lock()
  12.         print(a)
  13. }

程序的结果也是 "hello,world"。对于 a 的赋值在 l.Unlock() 调用之前发生, l.Unlock() 在 l.Lock() 的第二次调用返回之前发生 ,l.Lock() 第二次调用又在 print 之前发生。

对于 sync.RWMutex 类型的变量 mutex 的每一次 mutex.RLock 调用,如果是在第 n 次 mutex.Unlock 调用了之后调用的,那么 mutex.Lock 的第 n+1 次调用就在其对应的 mutex.RUnlock 之后发生。[ 有点绕,不过大意貌似是说, 如果在 writer 锁 unlock 之后调用了多次 reader lock, 那么下一次获取 writer 锁要在前面的所有 reader unlock 之后, O__O"… ]

Once

sync 包中提供了一个安全机制用来实现多个 goroutine 只有一次的初始化,就是使用 Once 类型。多个线程都可以执行 once.Do(f),但是只有一个可以运行 f(),而其他的调用将被阻塞掉,直到 f() 返回。

只有一个调用(的返回)在所有的 once.Do(f) 返回之前发生。

下面的程序:

  1. var a string
  2. var once sync.Once
  3.  
  4. func setup() {
  5.         a = "hello, world"
  6. }
  7.  
  8. func doprint() {
  9.         once.Do(setup)
  10.         print(a)
  11. }
  12.  
  13. func twoprint() {
  14.         go doprint()
  15.         go doprint()
  16. }

调用 twoprint 会有两次 "hello,world" 的输出,而第一次对 doprint 的调用运行了一次 setup。

错误的同步

注意 : 对一个变量 v 的读 r 可能会察觉到一个与其并发的 goroutine 对 v 的写,但即使这发生了也不意味着,读 r 后面的读可以察觉到写 w 前面的写,O__O"… 比如下面的程序:

  1. var a, b int
  2.  
  3. func f() {
  4.         a = 1
  5.         b = 2
  6. }
  7.  
  8. func g() {
  9.         print(b)
  10.         print(a)
  11. }
  12.  
  13. func main() {
  14.         go f()
  15.         g()
  16. }

程序结果可能输出 2 和 0 。[ 输出 00 、 21、 01 想必都是认同的, 输出 20 呼应了前面说的不同的goroutine看到程序的执行顺序可能不同。 ]

就是这样一个简单的事实,让很多以前或是其他语言中使用的习惯做法不再适用。

二次检查加锁 (double-checked locking) 是一种为了避免同步开销而尝试的举措,举个例子,twoprint 程序可以如下改写,虽然不再正确:

  1. var a string
  2. var done bool
  3.  
  4. func setup() {
  5.         a = "hello, world"
  6.         done = true
  7. }
  8.  
  9. func doprint() {
  10.         if !done {
  11.                 once.Do(setup)
  12.         }
  13.         print(a)
  14. }
  15.  
  16. func twoprint() {
  17.         go doprint()
  18.         go doprint()
  19. }

us 不会保证,在 doprint 中 u 看到了 done 变化(也就是变成 ture) 同样意味着 u 可以看到 a 的变化(变成 "hello,world") 。这里的版本,可能会输出空串,而不是 "hello,world" [也就是落后的那一个的输出结果可能不是预期的 ... ]。

还有一个不正确的惯用法是忙等一个值,比如:

  1. var a string
  2. var done bool
  3.  
  4. func setup() {
  5.         a = "hello, world"
  6.         done = true
  7. }
  8.  
  9. func main() {
  10.         go setup()
  11.         for !done {
  12.         }
  13.         print(a)
  14. }

和前面的说法类似,不保证在 main 中看到 done 的变化同时也能看到 a 的变化,所以,这个程序也可能输出空串。更糟糕的一种情况是,因为在两个线程之间并没有同步控制事件,所以 done 的变化都不一定会为 main 所察觉,也就是 main 函数可能会永远死循环下去,O__O"… [ 对于使用其他线程库的人来说,他们相信,总有一天(除非世界末日该进程 abort 掉了) setup 会执行, 这个时候 main 就会看到 a 和 done 的变化, 然后 main 就结束了 ... ]。

在这个方向上,我们可以渐行渐远,来看看一个更为晦涩的程序:

  1. type T struct {
  2.         msg string
  3. }
  4.  
  5. var g *T
  6.  
  7. func setup() {
  8.         t := new(T)
  9.         t.msg = "hello, world"
  10.         g = t
  11. }
  12.  
  13. func main() {
  14.         go setup()
  15.         for g == nil {
  16.         }
  17.         print(g.msg)
  18. }

即使 main 中检测到 g != nil 然后退出了循环,也不要以为就可以看到 g.msg 的值初始化过了。

上面这些例子的解决方法都是一致的:请显式地使用同步

后话

自己琢磨了一下, go 的并发设计的特点主要有两点反常规 :

  • 并发的 goroutine 之间默认是互不相识的, 甚至可以认为对方不存在, O__O"… ;
  • 并发的代码段之间, 对于顺序的体会可能不一样,也就是 go 在语言层次允许代码顺序调整;(上面说了, 代码段之间的关系有“之前”、 “之后” 和 “并发”三种,不要想当然的不是 “之前” 就是 “之后” )

go 的并发是有点诡异,想想,它为什么要这样呢?因为在程序执行上调整顺序是很常见的优化措施, 也就是说 go 语言是更自然的“实现”语言, 但是不是更自然的“编程”语言, 因为 coding 的时候 me 们很多时候默认一切都是从上到下的,即使多线程亦如此 。 所以写 goroutine ,应该会多出来一些用来同步的代码。

Tags: 

Article type: