Go并发编程 — 内存模型

180 阅读4分钟

简介

Go 内存模型指定了在并发多 goroutine 读相同变量的时候,变量的可见性条件。也就是 goroutine 在读取一个变量的值的时候,能够看到其它 goroutine 对这个变量进行的写的结果。

指令重排和可见性问题

可见性问题

看一下这个例子,g1 goruntine 就是将a、b变量进行赋值,在 g2 goruntine 中打印 b 的值。需要注意的是,即使这里打印出 b 的值是 2,但是依然可能在打印 a 的值时,打印出初始值 0,而不是 1。这是因为,程序运行的时候,不能保证 g2 看到的 a 和 b 的赋值有先后关系。

看到这里,你可能会有疑问,我都运行这个程序几百万次了,怎么也没有观察到这种现象?能不能观察到和提供保证是两码事儿,由于 CPU 架构和 Go 编译器的不同,即使你运行程序时没有遇到这些现象,也不代表 Go 可以 100% 保证不会出现这些问题。

package main

var a, b int

func f() {
   a = 1
   b = 2
}
func g() {
   print(b)
   print(a)
}
func main() {
   go f() // g1
   g() // g2
}

指令重排

X = 0
for i in range(100):
    X = 1
    print X

假设我们写了上面这样的一段代码,在循环里面都只会打印出 100 次 1,那么编译器可能会将指令进行重新编排,优化成了下面这种方式

X = 1
for i in range(100):
    print X

这么优化完之后,单个 goroutine 中并不会改变程序的执行,这时候同样会输出 100 次 1 ,并且减少了 100 次赋值操作。但是,如果与此同时我们存在一个另外一个 goroutine 干了另外一个事情 X = 0, 那么这个输出就变的不可预知了,就有可能是 1001111101…,所以如果存在并发读写的时候,都需要进行串行化访问。

Happen Before

含义

Go 内存模型通过 happens-before 定义两个事件(读、写 action)的顺序:如果事件 e1 happens before 事件 e2,那么,我们就可以说事件 e2 在事件 e1 之后发生(happens after)。如果 e1 不是 happens before e2, 同时也不 happens after e2,那么,我们就可以说事件 e1 和 e2 是同时发生的。

在单一独立的 goruntine 中先行发生的顺序就是程序中表达的顺序

假设有个变量 v ,如果要保证对 变量 v 的读操作 r 可以观察到一个对 变量 v 的写操作 w,需要满足 2 个条件:

  • r 不能先行发生于 w
  • wr 前没有对 v 的其他的写操作

如果要保证 变量 v 的读操作 r 一定能观察到到一个对 变量 v 的写操作 w,需确保 w 是 r 允许看到的唯一写操作,在满足 2 个更为严格的条件:

  • w 发生在 r 之前
  • 其他对共享变量 v 的写操作要么发生在 w 之前或 r 之后

这个条件的要求比第一个条件更强,它需要确保没有其它写入操作与 w 或 r 并发。在单个 goroutine 当中这两个条件是等价的,因为单个 goroutine 中不存在并发,在多个 goroutine 中就必须使用同步语义来确保顺序,这样才能到保证能够监测到预期的写入。

重点

  • 在 Go 语言中,对变量进行零值的初始化就是一个写操作。
  • 如果对超过机器 word(64bit、32bit 或者其它)大小的值进行读写,那么,就可以看作是对拆成 word 大小的几个读写无序进行。

Go 保证的 happens-before 关系

init 函数

  • 如果一个包p导入包q,则 qinit函数的完成发生在任何pinit开始之前
  • main 函数的启动发生在所有 init 函数完成之后

goruntine创建

启动新 goroutine的 go 语句发生在 goroutine 开始执行之前,这里一定会输出 "hello,world" 的

var a string 

func f() { 
	print(a) 
} 

func hello() { 
	a = "hello, world" 
	go f() 
}

goruntine销毁

goroutine 退出的时候,是没有任何 happens-before 保证的。所以,如果你想观察某个 goroutine 的执行效果,你需要使用同步机制建立 happens-before 关系,比如 Mutex 或者 Channel

Channel

  • Channel 上的 send 发生在该通道的相应 send 的那次 Recv 完成之前
var c = make(chan int, 10) 
var a string 

func f() { 
	a = "hello, world" 
	c <- 0 
} 

func main() { 
	go f() 
	<-c 
	print(a) 
}
  • close 一个 Channel 的调用,肯定发生在从关闭的 Channel 中读取出一个零值之前

  • 对于 unbufferedChannel,也就是容量是 0 的 Channel,从此 Channel 中读取数据的调用一定发生在往此 Channel 发送数据的调用完成之前

var c = make(chan int)
var a string

func f() {
	a = "hello, world"
	<-c
}

func main() {
	go f()
	c <- 0
	print(a)
}

不正确的同步方式

这是一种典型的不正确的同步方式,一个 goruntine 读取另外一个 goruntine 设置的值,来做逻辑判断,不能保证观察到done 意味着观察到写入a

var a string 
var done bool 

func setup() { 
	a = "hello, world" 
	done = true 
} 

func main() { 
	go setup() 
	for !done { 
	} 
	print(a) 
}

参考

go.dev/ref/mem 建议仔细阅读