牛年牛逼轰轰~~
对于Gopher来说,Golang内存模型的地位极其重要也极容易被忽略。本片翻译自Golang官方文档The Go Memory Model。Golang的版本频繁迭代,但是这篇官方文档自2014年5月31日至今都未曾变过,可见其设计。
介绍
Go内存模型指定了一种条件,在这种条件下,可以保证在一个goroutine中读取变量可以观察到在不同goroutine中写入同一变量所产生的值。
建议
当修改由多个goroutine同时访问的数据的时候,必须将这种访问程序序列化。为了序列化访问,需要使用通道操作或其他同步原语(例如sync和sync/atomic包中的原语)保护数据。如果您必须阅读本文档的其余部分以了解程序的行为,那么您就太聪明了。不要自作聪明。
Happens Before
在单个goroutine中,读取和写入的行为必须像它们按照程序指定的顺序执行一样。也就是说,仅当重新排序不会改变语言规范所定义的该goroutine中的行为时,编译器和处理器才可以对单个goroutine中执行的读取和写入进行重新排序。由于指令重排,一个goroutine观察到的执行顺序可能不同于另一个goroutine所察觉到的执行顺序。例如,如果执行一个goroutine a = 1; b = 2;,则另一个goroutine可能会在的更新值b之前观察到的更新值a。
为了指定读写要求,我们在Go程序中定义Happens Before,该顺序是执行内存操作的部分顺序。如果事件e1发生在事件e2之前,那么我们说e2发生在e1之后。另外,如果e1不发生再e2之前也发生在e2之后,那么我们说,e1和e2同时发生。
在单个goroutine中,事前发生顺序是程序表示的顺序。
如果满足以下两个条件成立的时候,对一个变量v的读r可以观察到对变量v的写w:
1、r在w之前不会发生
2、在w之后r之前不会有其他的写操作w’改变v
为了保证一个变量v的读r观察到一个特定的对v的写w,需要确保w是唯一允许r观察的写。也就是说,如果下面两个条件都成立,r的读取保证会符合w的写入:
1、w发生在r之前
2、对共享变量v的任何其他写入要么发生在w之前,要么发生在r之后
这一对条件比第一对强,它要求与w或r同时不发生其他写操作。
因为单个goroutine没有并发性,所以这两个定义是等效的:一个读r观察到最近写入w到的值v。当多个goroutine访问一个共享变量时v,以确保读取观察到所需的写入,它们必须使用同步事件来建立事件发生。
对变量v的类型初始化为0值,就像在内存模型中写一样。
对大于单个机器字的值的读和写表现为以未指定顺序进行的多个机器字大小的操作。
同步
初始化
程序初始化在单个goroutine中运行,但是该goroutine可以创建其他并发运行的goroutine。
如果一个包p导入了包q,那么q的init函数的完成将发生在任何p的开始之前。
函数main的开始。Main发生在所有init函数完成之后。
Goroutine创建
开始一个新的goroutine的go语句发生在goroutine的执行开始之前。
例如,在这个程序中:
1 | var a string |
调用hello将在未来的某个时刻(可能在hello返回之后)打印“hello, world”。
Goroutine销毁
不能保证goroutine的退出发生在程序中的任何事件之前。例如,在这个程序中:
1 | var a string |
对a的赋值没有跟随任何同步事件,所以不能保证它被任何其他goroutine观察到。事实上,编译器可能会删除整个go语句。
如果一个goroutine的效果必须被另一个goroutine观察到,那么使用一个同步机制,例如锁或通道通信来建立一个相对的排序。
Channel通信
Channel通信是实现goroutine间同步的主要方法。一个特定Channel上的每一个发送都与来自该通道的相应接收相匹配,通常是不在同一个goroutine中。
Channel上的发送发生在该通道对应的接收完成之前。
这个程序:
1 | var c = make(chan int, 10) |
这里可以保证打印”hello, world”。 对a的写入发生在c的发送之前,发送发生在c相应的接收完成之前,接收发生在打印之前。
Channel关闭发生在由于通道关闭而返回零值的接收之前,因为通道已关闭。
在前面的例子中,用close(c)替换c <- 0会得到一个具有相同保证行为的程序。
来自非缓冲Channel的接收发生在该通道上的发送完成之前。
下面的程序(如上所述,但是交换了send和receive语句并使用了一个非缓冲Channel):
1 | var c = make(chan int) |
这样也能保证打印”hello, world”。对a的写入发生在c的接收之前,接收发生在c完成相应的发送之前,发送发生在打印之前。
如果通道是缓冲的(例如,c = make(chan int, 1)),那么程序将不能保证输出”hello, world”。(它可能打印空字符串,崩溃,或做其他事情。)
容量为C的Channel上的第k次接收发生在该信道完成第k+C次发送之前。
该规则将前一个规则推广到缓冲的Channel。它允许通过缓冲的Channel对计数信号量进行建模:Channel中的项目数与活动使用次数相对应,Channel的容量与同时使用的最大次数相对应,发送项目获取信号量,然后接收项目会释放信号量。这是限制并发性的常见习惯用法。
这个程序为工作列表中的每个条目启动一个goroutine,但是goroutines使用limit(Channel)进行协调,以确保一次最多有三个工作函数在运行。
1 | var limit = make(chan int, 3) |
锁
该sync软件包实现了两种锁定数据类型sync.Mutex和sync.RWMutex。
对于任何sync.Mutex或sync.RWMutex,变量l和n < m,调用l. unlock()的n发生在调用l.lock()的m之前。
程序:
1 | var l sync.Mutex |
保证打印”hello, world”。第一次调用l.Unlock()(在f中)发生在第二次调用l.Lock()(在main中)返回之前,后者发生在打印之前。
对于l.RLock对sync.RWMutex变量的任何调用l,都存在一个n,使得l.RLock在调用n到之后发生(返回),l.Unlock并且匹配l.RUnlock发生在调用n +1到之前l.Lock。
Once
该sync包通过使用Once类型为在存在多个goroutine的情况下初始化提供了一种安全的机制。once.Do(f)一个特定的线程可以执行多个线程f,但是只有一个线程可以运行f(),其他线程将阻塞直到f()返回。
在once.Do(f)中对f()的单个调用发生(返回)在任何调用once.Do(f)返回之前。
程序:
1 | var a string |
调用twoprint()将恰好调用一次setup()。setup()函数将在任何一次print()调用之前完成。结果是“hello, world”将打印两次。
不正确的同步
请注意,一个读r可能会观察到与r并发的写w所写的值。即使发生了这种情况,也并不意味着在r之后发生的读会观察到在w之前发生的写。
程序:
1 | var a, b int |
g()可能会打印2,然后再打印0。
这个事实使一些惯用语无效。
Double-checked locking是为了避免同步的开销。例如,双打印程序可能被错误地写成:
1 | var a string |
但不能保证,在doprint()中,观察写入到完成意味着观察写入到a。这个版本可以(错误地)打印一个空字符串,而不是”hello, world”。
另一个不正确的习惯用法是忙于等待一个值,例如:
1 | var a string |
和以前一样,基本上不能保证在main中观察到done就意味着观察到对a的写,因此该程序也可能打印一个空字符串。更糟的是,不保证在写done永远不会被观察到main,因为两个goroutine之间没有同步活动。main不能保证循环完成。
这个主题还有一些更微妙的变体,比如这个程序:
1 | type T struct { |
即使main观察了g != nil并退出了它的循环,也不能保证它会观察g.msg的初始化值。
在所有这些示例中,解决方案都是相同的:使用显式同步。