http://hugozhu.myalert.info/2013/04/20/31-golang-memory-model.html
执行体 - Go里的Goroutine或Java中的Thread
内存模型的目的是为了定义清楚变量的读写在不同执行体里的可见性。理解内存模型在并发编程中非常重要,因为代码的执行顺序和书写的逻辑顺序并不会完全一致,甚至在编译期间编译器也有可能重排代码以最优化CPU执行, 另外还因为有CPU缓存的存在,内存的数据不一定会及时更新,这样对内存中的同一个变量读和写也不一定和期望一样。
和Java的内存模型规范类似,Go语言也有一个内存模型,相对JMM来说,Go的内存模型比较简单,Go的并发模型是基于CSP(Communicating Sequential Process)的,不同的Goroutine通过一种叫Channel的数据结构来通信;Java的并发模型则基于多线程和共享内存,有较多的概念(violatie, lock, final, construct, thread, atomic等)和场景,当然java.util.concurrent并发工具包大大简化了Java并发编程。
Go内存模型规范了在什么条件下一个Goroutine对某个变量的修改一定对其它Goroutine可见。
在一个单独的Goroutine里,对变量的读写和代码的书写顺序一致。比如以下的代码:
package main
import (
"log"
)
var a, b, c int
func main() {
a = 1
b = 2
c = a + 2
log.Println(a, b, c)
}
尽管在编译期和执行期,编译器和CPU都有可能重排代码,比如,先执行b=2,再执行a=1,但c=a+2是保证在a=1后执行的。这样最后的执行结果一定是1 2 3
,不会是1 2 2
。但下面的代码则可能会输出0 0 0
,1 2 2
, 0 2 3
(b=2比a=1先执行), 1 2 3
等各种可能。
package main
import (
"log"
)
var a, b, c int
func main() {
go func() {
a = 1
b = 2
}()
go func() {
c = a + 2
}()
log.Println(a, b, c)
}
Happens-before用来指明Go程序里的内存操作的局部顺序。如果一个内存操作事件e1 happens-before e2,则e2 happens-after e1也成立;如果e1不是happens-before e2,也不是happens-after e2,则e1和e2是并发的。
在这个定义之下,如果以下情况满足,则对变量(v)的内存写操作(w)对一个内存读操作(r)来说允许可见的:
为了保证对变量(v)的一个特定写操作(w)对一个读操作(r)可见,就需要确保w是r唯一允许的写操作,于是如果以下情况满足,则对变量(v)的内存写操作(w)对一个内存读操作(r)来说保证可见的:
可以看出后一种约定情况比前一种更严格,这种情况要求没有w或r没有其他的并发写操作。
在单个Goroutine里,因为肯定没有并发,上面两种情况是等价的。对变量v的读操作可以读到最近一次写操作的值(这个应该很容易理解)。但在多个Goroutine里如果要访问一个共享变量,我们就必须使用同步工具来建立happens-before条件,来保证对该变量的读操作能读到期望的修改值。
要保证并行执行体对共享变量的顺序访问方法就是用锁。Java和Go在这点上是一致的。
以下是具体的可被利用的Go语言的happens-before规则,从本质上来讲,happens-before规则确定了CPU缓冲和主存的同步时间点(通过内存屏障等指令),从而使得对变量的读写顺序可被确定–也就是我们通常说的“同步”。
package main
import (
"log"
"time"
)
var a, b, c int
func main() {
a = 1
b = 2
go func() {
c = a + 2
log.Println(a, b, c)
}()
time.Sleep(1 * time.Second)
}
利用这条happens-before,我们可以确定c=a+2
是happens-aftera=1和b=2
,所以结果输出是可以确定的1 2 3
,但如果是下面这样的代码,输出就不确定了,有可能是1 2 3
或0 0 2
func main() {
go func() {
c = a + 2
log.Println(a, b, c)
}()
a = 1
b = 2
time.Sleep(1 * time.Second)
}
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}
上面代码因为a="hello"
没有使用同步事件,并不能保证这个赋值被主goroutine可见。事实上,极度优化的Go编译器甚至可以完全删除这行代码go func() { a = "hello" }()
。
Goroutine对变量的修改需要让对其它Goroutine可见,除了使用锁来同步外还可以用Channel。
在Go编程中,Channel是被推荐的执行体间通信的方法,Go的编译器和运行态都会尽力对其优化。
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 = "hello, world"
happens-before c <- 0
,print(a)
happens-after <-c
, 根据上面的规则1)以及happens-before的可传递性,a = "hello, world"
happens-beforeprint(a)
。
根据规则2)把c<-0
替换成close(c)
也能保证输出hello,world
,因为关闭操作在<-c
接收到0之前发送。
var c = make(chan int)
var a string
func f() {
a = "hello, world"
<-c
}
func main() {
go f()
c <- 0
print(a)
}
根据规则3),因为c是不带缓冲的Channel,a = "hello, world"
happens-before <-c
happens-beforec <- 0
happens-before print(a)
, 但如果c是缓冲队列,如定义c = make(chan int, 1)
, 那结果就不确定了。
sync
包实现了两种锁数据结构:
其happens-before规则和Java的也类似:
l.Unlock()
happens-before 第m次l.lock()
调用返回。
var l sync.Mutex
var a string
func f() {
a = "hello, world"
l.Unlock()
}
func main() {
l.Lock()
go f()
l.Lock()
print(a)
}
a = "hello, world"
happens-before l.Unlock()
happens-before 第二个 l.Lock()
happens-beforeprint(a)
sync
包还提供了一个安全的初始化工具Once。还记得Java的Singleton设计模式,double-check,甚至triple-check的各种单例初始化方法吗?Go则提供了一个标准的方法。
once.Do(f)
中的f()
happens-before 任何多个once.Do(f)调用的返回,且f()有且只有一次调用。
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()
}
上面的代码虽然调用两次doprint()
,但实际上setup
只会执行一次,并且并发的once.Do(setup)
都会等待setup
返回后再继续执行。