前言
本篇译文对应的原文
标题:The Go Memory Model - Go References
作者:Go官方文档
地址:https://golang.org/ref/mem
本文标明yoko备注
的内容是我自己写的备注,其余的都是对英文原文的翻译。
目录
- 简介
- 建议
- Happens Before
- 同步
- init function
- 创建协程
- 销毁协程
- 使用channel通信
- 锁
- Once
- 示范错误的同步原语使用方法
简介
golang的内存模型指定了想要达到以下这种效果所依赖的条件。达到什么效果?即在一个协程中修改一个变量,在另外一个协程读取这个变量时需保证读取到修改后的值。
建议
程序如果修改被多个协程同时访问的数据,那么必须串行化这些访问操作。
为了保证串行化访问,可以使用golang的channel操作或者使用sync和sync/atomic包中的同步原语来保护数据。
如果你需要通过阅读这个文档的剩余部分才能搞明白你程序的行为,那么你有些太聪明了。
额,不要聪明过头了。
Happens Before
在一个协程内,读写操作的真正执行顺序必须保证它们所表现出的行为和程序中指定的顺序一致。也就是说,编译器和处理器只会在不改变这个协程内的程序语意的前提下重排序这个协程内的读写操作。由于存在这种重排序,一个协程观察到的执行顺序可能会和另一个协程观察到的不同。举例来说,如果一个协程执行了a=1; b=2;
,另一个协程可能观察到的是b变量的更新发生在a变量的更新之前。
为了说明对读写操作的要求,我们定义了happens before
,一种golang程序对内存操作的局部执行顺序。如果事件e1happens before
事件e2,那么我们说e2happens after
e1。同样的,如果e1不happens before
e2并且e1也不happens after
e2,那么我们说e1和e2happens concurrently
。
1 | yoko备注: |
在单个协程内,happens before
顺序就是程序中所描述的顺序。
对变量v的读操作r allow观察到对变量v的写操作w(yoko备注:这里的allow是允许的意思,即有可能观察到,也可能观察不到)
,需同时满足以下条件:
- r
not happens before
w - 没有其它的
happens after
w并且happens before
r的写操作
如果要保证对变量v的读操作r要观察到特定的对变量v的写操作w,即w是r唯一允许被观察到的写操作。简单来说,要保证r观察到w,需同时满足以下条件:
- w
happens before
r - 其它对共享变量v的写操作要么
happens before
w,要么happens after
r
这组条件的限定要比第一组的条件限定更严格些。它要求了没有其它的写操作和w/rhappens concurrently
。
在一个协程中,由于没有并发,所以这两种定义是相同的:读操作r可以观察到最近一次的写操作w。如果是多协程访问一个共享变量。那么必须用同步事件来建立起happens before
的条件以保证读操作观察到期望的写操作。
对变量v的初始化操作,其行为和在内存模型中做一次写操作是一样的。
对变量超过一个机器字长大小的读写操作,其行为和多个单机器字长大小的操作一样,是一种未指定的顺序。
同步
init function
golang程序中的所有init function运行在同一个协程中,但是这个协程可能会创建其它的协程,而这些协程可能会并发运行。
如果p包内引入了q包,那么q的init函数将完全执行完后再开始执行p的init function。
程序的入口函数main.main
在所有init function都执行完后再执行。
创建协程
开启一个新协程happens before
这个新协程的执行入口处。
比如下面这个程序:
1 | var a string |
调用hello函数后会在未来的某个时间点打印”hello, world”,这个时间点有可能是hello函数执行结束以后。
销毁协程
协程退出并不保证happens before
程序中的任何事件。比如下面这个程序:
1 | var a string |
赋值语句并没有和任何同步事件相结合,所以并不能保证这个赋值语句被任何其他协程观察到。事实上,一个激进的编译器可能把整个协程语句都删除掉。
如果一个协程造成的影响需要被其他协程观察到,需使用锁或channel等同步机制来建立一个关联顺序。
使用channel通信
使用channel通信是多协程同步的主要方法。每次往一个特定的channel发送都和一个相关联的从channel接收相匹配,一般发送和接收在不同的协程上。
往带缓冲的channel发送happens before
从该channel完成接收。(yoko备注:即channel的接收处会阻塞,直到其他协程往channel发送了数据)
这个程序:
1 | var c = make(chan int, 10) |
会保证打印”hello, world”。对a的写入happens before
往channel c发送,往channel c发送happens before
从channel c完成接收,从channel c完成接收happens before
打印。
关闭一个channel happens before
从channel的接收处返回0值。
在前面的例子,将c <- 0
替换成close(c)
,程序会保证相同的行为。
从无缓冲channel接收happens before
往该channel完成发送。(yoko备注:即channel的发送处会阻塞,直到其他协程执行到从channel读取数据)
这个程序(和上面的程序相比,交换了发送和接收的语句并且使用了无缓存channel):
1 | var c = make(chan int) |
1 | func main() { |
同样会保证打印”hello, world”。写入a变量happens before
从c接收,从c接收happens before
往c完成发送,往c完成发送happens before
打印。
如果channel是有缓冲的(比如,c = make(chan int, 1)
),那么程序不能保证打印出”hello, world”。(可能会打印出空字符串,崩溃或者其他情况)。
第k次从初始化空间为C的channel的接收happens before
第k+C次往channel完成发送。
这个规则概况了前面那条同样是关于带缓冲channel的规则。它允许用带缓冲channel来实现计数信号量:channel里面元素的数量和当前实际使用的信号量数量相等,channel的初始化空间大小和最大能同时使用的信号量数量相等,往channel发送一个元素相当于获取信号量,从channel接收一个元素相当于释放信号量。这是一种常见的限制并发量的手法。
这个程序为work列表的每一个元素开启了一个协程,但是这些协程用限制初始化大小的channel保证同一时刻最多有三个work在执行。
1 | var limit = make(chan int, 3) |
锁
sync包实现了两种锁数据类型,sync.Mutex和sync.RWMutex。
对于任何sync.Mutex或sync.RWMutex的锁变量l和两个描述次数的n和m(n < m
),调用第n次l.Unlock()happens before
调用第m次l.Lock()的返回。
这个程序:
1 | var l sync.Mutex |
会保证打印”hello, world”。第一次调用l.Unlock(在f函数中)happens before
第二次调用l.Lock()(在main函数中)的返回,第二次l.Lock()的返回happens before
打印。
对于sync.RWMutex变量l的任意调用l.RLock
,l.RLock
阻塞直到n次调用l.UnLock
,并且n次l.RUnlock
happens before
第n+1次调用l.Lock
。
1 | yoko备注: |
Once
sync包中的Once类型提供了一种在多协程环境下做初始化工作的安全机制。多线程可以为一个特定的函数f执行once.Do(f)
,但是只有一个f()
会被执行,并且其他的调用会阻塞直到f()
执行完毕。
多协程使用once.Do(f)
,只有唯一的那个被执行的f()
执行完并返回,然后其它的才返回。
这个程序:
1 | var a string |
调用twoprint只会调用setup一次。setup函数会在调用print之前执行完毕。结果是”hello, world”会打印两次。
示范错误的同步原语使用方法
记住,读操作r可能会观察到并发写操作w的结果。即使发生了这种情况,并不意味着在r之后的读也会观察到发生在r之前的w。
这个程序:
1 | var a, b int |
有可能出现g函数先打印2再打印0。
这个事实使某些写法变得是不正确的。
双检锁是一种尝试避免多余同步操作的手段。比如,twoprint程序可能被错误改写成:
1 | var a string |
但是这并不能保证,在doprint函数中,观察到对done的写入就等效于观察到对a的写入。这个错误的版本可能错误的打印出一个空字符串而不是”hello, world”。
另一种错误的写法是繁忙等待一个变量,就像:
1 | var a string |
就像前面所说,并不能保证在main函数中观察到对done的写入就意味着观察到对a的写入,所有这个程序也可能打印出一个空字符串。更糟糕的是,并不能保证对done的写入会被main函数观察到,因为两个线程间并没有使用同步事件。main函数中的循环并不能保证会结束。
还有一些关于这个主题的一些细小差别的其它场景,就像这个程序。
1 | type T struct { |
即使main函数观察到了g != nil
并且退出了这个循环,也不能保证main函数就能观察到对g.msg的修改。
所有的这些例子,解决方法都是相同的:使用显式的同步原语操作。
本文完,作者yoko,尊重劳动人民成果,转载请注明原文出处: https://pengrl.com/p/34119/