The Go Memory Model
一 介绍
go的内存模型指定一种条件,在这种条件下,可以保证在一个goroutine中读取变量可以观测到不同goroutine对同一变量的写操作所产生的值。
二 忠告
多goroutine并发访问修改数据的程序必须串行化访问。
利用channel操作、sync包中的同步原语以及sync/atomic包中的原子操作保证串行化访问,保护数据。
如果你一定要阅读该文档的其余部分去理解你编程的行为,这是非常明智的。
但是不要自作聪明。
三 happens before
在单一的goroutine内部,读取和写入的行为必须和程序中代码指定的执行顺序保持一致。也就是说,在重排不会改变语言规范所定义的goroutine行为时,编译器和处理器才可以对单个goroutine进行读取、写入指令的重排。因为指令重排,一个goroutine所观测到的执行顺序可能和其他goroutine所感知到的执行顺序不同。例如,当一个goroutine执行a=1;b=2,其他goroutine可能观测到b的值比a的值提前更新。
为了指定读取和写入的要求,我们在go程序中定义了执行内存操作之前的部分执行顺序。如果事件e1发生在事件e2之前,那么可以说事件e2在事件e1之后发生;同理,如果事件e1既不发生在事件e2之前,也不发生在事件e2之后,那么我们就说事件e1和事件e2同时发生。
在单goroutine内部,事前发生的顺序是程序表示的执行顺序
一个v变量的读操作r要观测到对v变量的写操作w,需要满足以下两个条件:
1.r不能先于w发生;
2.没有其他的对v的写操作*w’*发生在w之后,r之前;
为了保证变量v的读操作r可以观测到特定关于v的写操作w,必须要确保w是唯一一个能被r所观测,也就是说要保证r观测到w,必须满足以下两个条件:
1.w先于r发生;
2.其他对共享变量v的写操作要么先于w发生,要么后于r发生;
后者这一对条件比第一对条件更强,它要求没有其他写操作和w或r同时发生。
在单个goroutine中,没有并发性,所以两种定义是相等的,v的读操作r总能观测到最近一次对v的写操作w的值。当多goroutine访问共享变量v时,要确保读取到期望写入的值,就必须使用同步事件建立事件发生顺序的条件。
变量v的零值初始化行为在内存模型中就是一个写操作。
超过单个机器字的值的读取或写入会被拆成多个没有指定顺序的机器字大小的操作。
四 Go中Happens before关系保证
Initialization(初始化)
程序在单个运行的goroutine中初始化,但是该goroutine可以创建其他的goroutine,同时执行。
如果包p导入了包q,那么q的初始化函数init完成先于任何p的开始之前。
main.main函数的执行发生在所有初始化函数执行完成之后。
Goroutine
启动新goroutine的go申明语句在该goroutine执行开始前发生。
例如在下面这段程序中:
1
2
3
4
5
6
7
8
9var a string
func f() {
fmt.Println(a)
}
func hello() {
a = "hello world"
go f()
}调用hello函数,在将来某个点打印出”hello world”,(可能在hello返回之后)
在程序中,不能保证一个goroutine的退出发生在任何事件之前,例如下面的这个程序:
1
2
3
4
5
6
7var a string
func hello() {
go func(){
a = "hello"
}()
fmt.Println(a)
}a的赋值没有跟随任何同步事件,所以不能保证其他的goroutine能观测到a的赋值。事实上,编译器可能会主动删除掉整个go的申明。
如果一个goroutine的影响必须让其他的goroutine观测到,就要使用一些如锁、channel通讯等同步机制建立相对的顺序。
Channel
通道通行是goroutine之间同步的主要方法。通常在不同的goroutine中,在特定通道上的每一个发送操作与该通道上的接收操作进行匹配。
在一个通道上的发送操作发生在该通道上相应的接收操作完成之前。
这个程序:
1
2
3
4
5
6
7
8
9
10
11var c = make(chan int, 10)
var a string
func f() {
a = "hello world"
c <- 0
}
func main() {
go f()
<- c
fmt.Println(a)
}上面保证了一定会输出”hello world”。a的写入happens before通道c的发送,c的send操作happens before从通道c匹配接收操作完成,a的打印操作happens after通道的接收操作。
通道关闭发生在因为通道关闭返回一个零值的接收操作之前
在上面那个例子中,将c <- 0的操作替换为*close(c)*,能得到一个和上述相同行为的程序。
从一个无缓冲通道的接收操作发生在该通道的发送操作完成之前
这个程序(如上所示,使用一个无缓冲通道,并交互了发送和接收操作):
1
2
3
4
5
6
7
8
9
10
11
12
13var c = make(chan int)
var a string
func f() {
a = "hello world"
<- c
}
func main() {
go f()
c <- 0
fmt.Println(a)
}也能保证”hello world”输出,变量a的写操作发生在通道c的接收操作之前,通道c的接收操作发生在匹配发送操作完成之前,而发送操作发生在打印操作之前。
如果是有缓存通道,例如(var c = make(chan int, 1)),上面程序不能保证输出”hello world”。(可能会输出空的字符串、崩溃、或执行其他操作)
容量为C的通道上的第K个接收操作发生在该通道的K+N个发送完成操作之前
这个规则将前面规则推广到缓冲通道,通过缓存通道对计数信号量进行建模,通道中元素的数量对应了正在使用的数量,通道的容量对应了并发使用的最大数量。发送一个元素获取一个信号量,接收一个元素释放一个信号量。这是限制并发常用的方法。
该程序为工作列表中每一个元素启动一个goroutine,但是goroutine使用通道进行任务协调,以确保每个时刻最多有三个工作函数执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func(w){
limit <- 1
go w()
<- limit
}(w)
select {
}
}
}Locks
go中sync包提供了两种锁数据类型,sync.Mutex、sync.RWMutex。
对于任何类型sync.Mutex, sync.RWMutex类型变量l,当n<m, 第n次l.UnLock()的调用发生在第m次l.Lock()返回之前。
这个程序:
1
2
3
4
5
6
7
8
9
10
11
12
13
14var l sync.Mutex
var a string
func f() {
a = "hello, world"
l.Unlock()
}
func main() {
l.Lock()
go f()
l.Lock()
fmt.Println(a)
}能够保证”hello,world”的正常输出。第一次在f函数中调用的l.UnLock()发生在main.main函数中第二次调用l.Lock()返回结果之前。而第二次调用l.Lock()发生在打印操作操作之前。
对于sync.RWMutex变量l,第n次的l.RLock()发生在n次的l.UnLock()之后,并且与之匹配的l.RUnLock()发生在第n+1次l.Lock()之前。(ps:go中读写锁是写优先模型)
Once
sync包通过使用Once类型提供了一种安全的机制,用于在存在多个goroutine的情况下进行初始化。多线程可以为了特定函数执行once.Do(f),但是只会运行一个f()函数,其他线程调用将会被阻塞,直到f()函数执行返回。
对于once.Do(f)的调用,f函数的单次调用一定happens before任何once.Do(f)调用返回
下面程序:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18var 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函数只会被调用一次,设置函数会在两次打印操作之前完成变量的赋值,”hello,wrold”的结果将会被打印两次。