Go 的内存模型
介绍
Go 的内存模型是可以让多个 goroutine 共享数据的,但指定了条件,在这种条件下,保证一个 goroutine 中可以读取另一个 goroutine 中写入的变量值。
建议
程序中有多个 goroutine 同时去更新数据时,必须用序列化的方式。比如用 channel 操作或 sync、sync/atomic 的同步原语。
意思是:需要同步的场景,使用显式的同步!显式的同步!!显式的!!!
Happens Before
Happens-before 是一个顺序规范,准确地说是规定了部分顺序,让某些事件发生在另一些事件之前。
单个 goroutine
在单个 goroutine 中,读和写操作必须 表现得像 是他们是按程序的执行顺序进行的。
也就是说,实际上,在单个 goroutine 中编译器和执行器会对读操作和写操作的顺序进行重排,但它保证了这个重排不会改变 语言规范 中所定义的行为。
由于重排,一个 goroutine 观察到的执行顺序可能与另一个 goroutine 的不同。
比如,一个 goroutine 执行 a = 1; b = 2
,那另一个 goroutine 可能先观察到 b == 2
而此时 a != 1
。如下测试代码:
var count = new(uint64)
func main() {
for i := 0; i < 1000000; i++ {
test()
}
time.Sleep(time.Second)
fmt.Println(*count) // 输出不为 0
}
func test() {
var a, b int
go func() {
A, B := a, b
if B == 2 && A != 1 {
atomic.AddUint64(count, 1)
}
}()
go func() {
a = 1
b = 2
}()
}
为了指定读和写的要求,我们在 Go 中定义了 Happens before 的概念——Go 执行内存操作的部分顺序。
如果事件 e1 在 e2 之前发生,我们可以说 e2 发生在 e1 之后。如果 e1 即不在 e2 之前发生也不在 e2 之后发生,我们称为 e1 和 e2 是同时发生。
在单 goroutine 中,happens-before 顺序就是程序语言所表达的顺序。
当满足以下两个条件时,一个变量(v)的读操作(r)允许观察它的写操作(w):
- 读不发生在写之前;
- 在此写操作之后或此读之前没有其他的写操作(w')发生。
为了保证上述读操作(r)能观察到上述的写操作(w),即需要保证以下两个条件:
- 写发生在读之前;
- 其他任何对 v 的写操作(w', w''...),或者发生在 w 之前,或者发生在 r 之后。
这两个条件比上面的两个条件更健壮。它要求了这里没有其他的写操作与这里的 r 和 w 并发。
在单协程中没有并发,所以读操作(r)总能观察到最近一次写操作(w)的值。
而在多协程中,我们就必须用同步事件来构建 happens-before 条件来确保读观察到了期望的写入。
对于一个变量初始化为它的零值时,表现为在内存模型中的一个写操作。
对一个大于 单机器字 的读和写操作表现为对一个未指明顺序的 多机器字大小(multiple machine-word-sized)的操作。
同步(Synchronization)
初始化(Initialization)
- 程序初始会运行一个单协程,但这个协程可能会创建并发执行的其他协程。
- 如果 p 包引入了 q 包,那 q 的
init()
函数发生在 p 的任何操作之前(包括 p 的init()
)。 -
main.main()
(main 包下的main()
)发生在所有的init()
函数之后。
协程的创建
- go 命令开启一个 go 协程发生在此 go 协程执行之前。(即:先创建再执行。这句话的意思是说 go 协程创建之前的语句和这个 go 协程没有并发问题)
协程的销毁
- 协程的退出不保证发生在程序的任何事情之前。(有点拗口,就是指协程的执行时间和退出正常情况下完全独立,没有任何时间保证)
(我们初学协程时,常常会写 main 函数的最后一句创建 go 协程,结果导致主程序结束了协程还没执行。就是这个意思)
确保并发下同步的三种方式
用 channel
- 向 channel 中写数据发生在 对应 的读操作之前(即读时保证先完成写操作);
- channel 的关闭操作发生在读操作之前,由于通道关闭,返回它对应类型的零值;
- 从无缓冲的 channel 中读操作,发生在向此 channel 的写完成之前;
用锁
sync 包提供了两种锁 sync.Mutex
和 sync.RWMutex
-
Lock()
之后,并发协程中的Unlock()
一定发生在其他协程Lock()
之前
用 Once
var once sync.Once
...
func doprint() {
once.Do(setup)
print(setup)
}
- Once 保证了
once.Do()
只执行一次
总结
这里注意的内容主要有以下几点:
- Go 的 goroutine 是共享内存的;
- Happends-before 原则;
- 编译器和执行器会对编码的执行顺序进行重排,但在单协程中对外表现一致;
- 学会用几种方式保证协程中读写的顺序(与自己期望的一致);
- 使用显示的同步做同步!!!
不正确的同步:
var a string
var done bool
func setup() {
a = "hello, world" //
done = true // 由于这两个的赋值操作不一定那个先
}
func main() {
go setup()
for !done {
}
print(a)
}
附:
- 官方文档地址The Go Membery Model