Go内存模型
英文原文:
[The Go Memory Model]: https://golang.org/ref/mem
介绍
go语言的内存模型规定了一个goroutine
对某个变量的值修改,另一个goroutine
可以感知到值变化的条件。
建议
多个goroutine
并发访问同一个变量的时候,需要让这些并发访问操作顺序化。用通道或其他同步原语(例如sync
包和sync/atomic
包)保证序列化访问
Happens Before
在单goroutine
中,只要指令重排不改变程序中定义的行为,编译器和处理器可以改变程序定义的读写顺序,也就是在单一goroutine 中Happens Before所要表达的顺序就是程序执行的顺序。但是在多个goroutine时候就可能存在问题,比如在一个goroutine中执行如下语句
a = 1
b = 2
但是,在另一个goroutine中,可能会先看到b的值更新后才看到a的值更新。
为了保证多goroutine下读写共享变量的正确性,go语言中引入happens before原则,即在go程序中定义了多个内存操作执行的一种偏序关系。如果操作e1先于e2发生,我们说e2 happens after e1,如果e1操作既不先于e2发生又不晚于e2发生,我们说e1操作与e2操作并发发生。
单goroutine
在单一goroutine 中Happens Before所要表达的顺序就是程序执行的顺序。同时满足下面两个条件时,对一个变量v
的写操作w
对读操作r
可见:
- 读操作
r
没有发生在写操作w
前 - 在写操作
w
之后,读操作r
之前没有其他的写操作w'
对变量v
进行修改
多goroutine
在一个goroutine里面,不存在并发,所以对变量的读操作r
总是对最近的一个写操作w
的内容可见,但是在多goroutine下则需要同时满足下面两个条件才能保证写操作w
对读操作r
可见:
- 写操作
w
发生在读操作r
之前 ( w happens before r ) - 对共享变量
v
的其他写操作w'
要么发生在写操作w
之前或者发生在读操作r
之后 ( w’ hannens before w || w’ happens after r )
这一对条件比第一对条件要更加严格,它要求没有其他写操作w'
与当前写操作w
或读操作r
并行发生。在单一goroutine下不存在并发,所以前面两对条件是等价的:一个对共享变量v
的读操作r
总是对最近的一次写操作w
的内容可见。但是当有多个goroutines并发访问共享变量v
的时候,就需要引入同步机制建立happen-before条件来确保读操作r
对写操作w
写的内容可见。
在go内存模型中将多个goroutine中用到的全局变量初始化为它的类型零值在内存被视为一次写操作,另外当读取一个类型大小比机器字长大的变量的值时候表现为是对多个机器字的多次读取,但是读取顺序是不确定的,go中使用sync/atomic包中的Load和Store操作可以解决这个问题。
同步
初始化
程序的初始化是发生在一个goroutine内的,这个goroutine可以创建多个新的goroutine,创建的goroutine和当前的goroutine可以并发的运行。
如果在一个goroutine所在的源码包p里面通过import命令导入了包q,那么q包里面go文件的初始化方法的执行会happens before 于包p里面的初始化方法执行
创建goroutine
go语句启动一个新的goroutine的动作 happen before 该新goroutine的运行。例如
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
如上代码调用hello方法后可能不会输出”hello,world”, 也可能会输出”hello world“。因为在上面的代码中没用同步机制确保多个goroutine对内存操作的偏序关系
goroutine 销毁
一个goroutine的销毁操作并不能确保程序中任何事件的 happen before ,比如下面例子:
var a string
func hello() {
go func() {
a = "hello"
}()
print(a)
}
如上代码 goroutine内对变量a的赋值并没有加任何同步措施,所以并能不保证hello函数所在的goroutine对变量a的赋值可见。如果要确保一个goroutine对变量的修改对其他goroutine可见,必须使用一定的同步机制,比如锁、通道来建立对同一个变量读写的偏序关系。
通道通信
通道通信是goroutine间同步的主要方法,在多个goroutine中,每个对通道进行写操作的goroutine都对应着一个从通道读操作的goroutine。
有缓冲区通道
对有缓冲区通道的写操作happens before 对通道的读操作 ( w happens befor r )
例如下面的代码:
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
的写操作 happen before 向通道c
写入数据的操作;而向通道c
写入数据的操作happen before 从通道c
读取数据完成的操作;而从通道c
读取数据完成的操作 happen before 打印变量a
的操作。
另外关闭通道的操作 happen before 从通道接受0值(关闭通道后会向通道发送一个0值),在上面代码中将c <-0
替换成close(c)
也可以确保输出”hello, world”。
下面代码用close©替换 c <- 0 效果是一样的
var c = make(chan int, 10)
var a string
func f() {
a = "hello, world"
//c <- 0
close(c)
}
func main() {
go f()
<-c
print(a)
}
注:在有缓冲通道中通过向通道写入一个数据总是 happen before 这个数据被从通道中读取完成,这个happen before规则使多个goroutine中对共享变量的并发访问变成了可预见的串行化操作。
无缓冲区通道
从无缓冲通道中读取数据的操作happens before向通道中写入数据的操作 ( r happens befor w )
例如下面的代码(与上面代码相比,只是把有缓冲区通道变成无缓冲区通道,同时交换交换了读写缓冲区操作的语句)
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
变量的写操作 happen before 从通道c
读取数据完毕的操作;从通道c
读取数据的操作 happen before 向通道c
写入数据完毕的操作;向通道c
写入数据的操作 happen before 打印变量a
的操作。
如上代码如果换成有缓冲的通道,比如c = make(chan int, 1) 则就不能保证一定会输出 ”hello, world”。
并发量控制
从容量为C的通道读取第K个元素 happen before 向通道第k+C次写入完成,比如从容量为1的通道读取第3个元素 happen before 向通道第3+1次写入完成。
这个规则对有缓冲通道和无缓冲通道的情况都适用,有缓冲的通道可以实现信号量计数的功能,比如通道的容量可以认为是最大信号量的个数,通道内当前元素个数可以认为是剩余的信号量个数,向通道写入(发送)一个元素可以认为是获取一个信号量,从通道读取(接受)一个元素可以认为是释放一个信号量,所以有缓冲的通道可以作为限制并发数的一个通用手段。
例如下面的代码,在 main goroutine里面为work列表里面的每个方法的执行开启了一个单独的goroutine,但是本程序使用缓冲区大小为3的通道limit
来做并发控制,保证同时只有3个work列表中的方法的 goroutine 可以并发运行。
var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func(w func()) {
limit <- 1
w()
<-limit
}(w)
}
select{}
}
锁
sync包实现了两个锁类型,分别为 sync.Mutex(互斥锁)和 sync.RWMutex(读写锁)。
对应任何sync.Mutex 或 sync.RWMutex类型的变量l
来说调用n
次 l.Unlock() 操作 happen before 调用m
次l.Lock()操作返回,其中n < m
,例如下面代码:
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”,其中对变量a
的赋值操作 happen before 第一次调用 l.Unlock()的操作;第一次调用 l.Unlock()的操作 happen before 第二次调用l.Lock()的操作;第二次调用l.Lock()的操作 happen before 打印输出a
的操作
对任何一个sync.RWMutex类型的变量l
来说,存在一个次数n
,调用 l.RLock操作 happens after 调用n
次 l. Unlock(释放写锁)并且相应的 l.RUnlock happen before 调用n + 1
次 l.Lock(写锁)
一次执行(Once)
sync包提供了在多个goroutine存在的情况下进行安全初始化的一种机制,这个机制也就是提供的Once类型。多 goroutine下,多个goroutine可以同时执行once.Do(f)方法,其中f
是一个函数,但是同时只有一个goroutine可以真正运行传递的f
函数,其他的goroutine则会阻塞直到运行f
的goroutine运行f
完毕。
多goroutine下同时调用once.Do(f)时候,真正执行f()函数的 goroutine happen before 任何其他由于调用once.Do(f)而被阻塞的goroutine返回,例如
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函数执行完成 happens before 任何对print的调用,运行上面的代码 "hello,world"将输出两次。
不正确的同步
案例1
需要注意的是虽然一个goroutine对一个变量的读取操作r
,可以观察到另外一个goroutine的写操作w
对变量的修改,但是这不意味着happening after 读操作r
的读操作可以看到 happens before写操作w
的写操作对变量的修改(需要注意这里的先后指的是代码里面声明的操作的先后顺序,而不是实际执行时候的先后顺序
var a, b int
func f() {
a = 1
b = 2
}
func g() {
print(b)
print(a)
}
func main() {
go f()
g()
}
比如上面代码一个可能的输出为先打印2,然后打印0
案例2
使用双重检查机制来避免使用同步带来的开销,如下代码:
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()
}
如上代码并不能保证一定输出hello, world,而可能输出空字符串,这是因为在doPrint函数内即使能够看到setup中对done变量的写操作,也不能保证在doPrint里面看到对变量a的写操作。
案例3
另外一个常见的不正确的同步是等待某个变量的值满足一定条件:
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func main() {
go setup()
for !done {
}
print(a)
}
该案例同理,并不能确保在main函数内即使可以看到对变量done的写操作,也看不到对变量a的操作,所以main函数还是可能会输出空串。更糟糕的是由于两个goroutine没有对变量done做同步措施,main函数所在goroutine可能看不到对done的写操作,从而导致main函数所在goroutine一直运行在for循环出。
这种不正常的同步方式有更微妙的变体,例如这个程序:
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函数内可以看到setup函数内对g的赋值,从而让main函数退出,但是也不能保证main函数可以看到对 g.msg的赋值,也就是可能输出空串
翻译过程中参考了如下文章
https://ifeve.com/golang-mem/