主要介绍了官方文档中介绍的 Go 内存模型的内容

资料

简介(Introduction)

Go 内存模型涉及到两个 Go协程 之间对同一个变量的读写。假如有一个变量,其中一个 Go协程(a) 写这个变量,另一个 Go协程(b) 读这个变量;Go 内存模型定义了什么情况下 Go协程(b) 能够确保读取到由 Go协程(a) 写入的值。

建议(Advice)

多协程并发修改数据的程序必须保证各个步骤串行执行。

为了串行执行各步,可以使用信道syncsync/atomic 两个包里的同步原语来保护被共享的数据。

因为并发编程是一件很复杂的事情,请大家在学习本文的基础上再多加练习,不能仅依赖本文来掌握并发编程的知识。

发生在…之前(Happens Before)

在一个 Go协程 里,对同一个变量的读写必然是按照代码编写的顺序来执行的。对于多个变量的读写,如果重新排序不影响代码逻辑的正常执行,编译器和处理器可能会对多个变量的读写过程重新排序;比如对于 a = 1; b = 2 这两个语句,在同一个 Go协程 里先执行 a=1 还是先执行 b=2 其实是没有区别的。但是,因为重新排列执行顺序的情况的存在,会导致某个 Go协程 所观察到的执行顺序可能与另一个 Go协程 观察到的执行顺序不一样。还是拿 a = 1; b = 2 举例,如果在某个协程里依次执行 a = 1; b = 2,由于重新排序的存在可能另一个 Go协程 观察到的事实是 b 的值先被更新,而 a 的值被后更新。

为了表征读写需求,我们可以定义“发生在…之前”,用来表示 Go 语言中某一小段内存命令的执行顺序。如果事件 e1 发生在事件 e2 之前,此时我们就认为 e2 发生在 e1 之后。如果事件 e1 既不发生在事件 e2 之前,也不发生在 e2 之后,此时我们就认为 e1e2 同时发生(并发)。

在一个 Go协程 内部,谁发生在谁之前的顺序就是代码显式定义的顺序。比如:

a := 1
fmt.Println(a)

肯定是 a := 1 先执行,fmt.Println(a) 后执行。

当 Go协程 不仅仅局限在一个的时候,存在下面两个规则:

规则一:如果存在一个变量 v,如果下面的两个条件都满足,则读操作 r 允许观察到(可能观察到,也可能观察不到)写操作 w 写入的值:

  1. r 不在 w 之前发生;
  2. 不存在其他的 w’w 之后发生,也不存在 w’r 之前发生。

规则二:为了保证读操作 r 读取到的是写操作 w 写入的值,需要确保 w 是唯一允许被 r 观察到的写操作。如果下面的两个条件都满足,则 r 保证能够观察到 w 写入的值:

  1. w 发生在 r 之前;
  2. 其他对共享变量 v 的写操作要么发生在 w 之前,要么发生在 r 之后。

规则二的条件比规则一的条件更为严格,它要求没有其他的写操作和 wr 并发地发生。

在一个 Go协程 里是不存在并发的,因此规则一和规则二是等效的:读操作 r 可以观察到最近一次写操作 w 写入的值。但是,当多个协程访问一个共享变量的时候,就必须使用同步事件来构建“发生在…之前”的条件,从而保证读操作观察到的一定是想要的写操作。

在内存模型中,变量 v 的零值初始化操作等同于一个写操作。

如果变量的值大于单机器字(CPU 从内存单次读取的字节数),那么 CPU 在读和写这个变量的时候是以一种不可预知顺序的多次执行单机器字的操作,这也是 sync/atomic 包存在的价值。

同步化(Synchronization)

初始化

程序的初始化是在一个单独的 Go协程 中进行的,但是这个协程可以创建其他的 Go协程 并且二者并发执行。

Go协程的创建

在 Go 语言中通过 go 语句创建新的 Go协程 发生在这个 Go协程 的执行之前。比如下面的例子:

var a string

func f() {
	print(a)
}

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

调用函数 hello 会在调用后的某个时间点打印 “hello, world” ,这个时间点可能在 hello 函数返回之前,也可能在 hello 函数返回之后。

Go协程的销毁

Go协程的退出无法确保发生在程序的某个事件之前。比如下面的例子:

var a string

func hello() {
	go func() { a = "hello" }()
	print(a)
}

其中 a 的赋值语句没有任何的同步事件,因此无法保证被其他任意的 Go 协程观察到这个赋值事件的存在。事实上,一些编译器可能会在编译阶段删除上面代码中的整个 go 语句。

如果某个 Go协程 里发生的事件必然要被另一个 Go协程 观察到,需要使用同步机制进行保证,比如使用或者信道(channel)通信来构建一个相对的事件发生顺序。

信道通信

信道通信是 Go协程 间事件同步的主要方式。在某个特定的信道上发送一个数据,则对应地可以在这个信道上接收一个数据,一般情况下是在不同的 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 的打印。因为信道的写入事件 c <- 0 发生在读取事件 <-c 之前,而 <-c 发生在 print(a)之前

规则二:信道的关闭事件发生在从信道接收到零值(由信道关闭触发)之前

在前面的例子中,可以使用 close(c) 来替代 c <- 0 语句来保证同样的效果。

规则三:对于没有缓存的信道,数据的接收事件发生在数据发送完成之前

比如下面的代码(类似上面给出的代码,但是使用了没有缓存的信道,且发送和接收的语句交换了一下):

var c = make(chan int)
var a string

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

上面这段代码依然可以保证可以打印 hello, world。因为信道的写入事件 c <- 0 发生在读取事件 <-c 之前,而 <-c 发生在写入事件 c <- 0 完成之前,同时写入事件 c <- 0 的完成发生在 print 之前

上面的代码,如果信道是带缓存的(比如 c = make(chan int, 1)),程序将不能保证会打印出 hello, world,它可能会打印出空字符串,也可能崩溃退出,或者表现出一些其他的症状。

规则四:对于容量为 C 的信道,接收第 k 个元素的事件发生在第 k+C 个元素的发送之前

规则四是规则三在带缓存的信道上的推广。它使得带缓存的信道可以模拟出计数信号量:信道中元素的个数表示活跃数,信道的容量表示最大的可并发数;发送一个元素意味着获取一个信号量,接收一个元素意味着释放这个信号量。这是一种常见的限制并发的用法。

下面的代码给工作列表中的每个入口都开启一个 Go协程,但是通过配合一个固定长度的信道保证了同时最多有 3 个运行的工作(最多 3 个并发)。

var limit = make(chan int, 3)

func main() {
	for _, w := range work {
		go func(w func()) {
			limit <- 1
			w()
			<-limit
		}(w)
	}
	select{}
}

sync 实现了两类锁数据类型,分别是 sync.Mutexsync.RWMutex

规则一:对于类型为 sync.Mutexsync.RWMutex 的变量 l,如果存在 n 和 m 且满足 n < m,则 l.Unlock() 的第 n 次调用返回发生在l.Lock()的第 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, worldl.Unlock()的第 1 次调用返回(在函数 f 内部)发生在 l.Lock() 的第 2 次调用返回之前,后者发生在 print 之前

规则二:存在类型 sync.RWMutex 的变量 l,如果 l.RLock 的调用返回发生在 l.Unlock 的第 n 次调用返回之后,那么其对应的 l.RUnlock 发生在 l.Lock 的第 n+1 次调用返回之前

单次运行

sync 还提供了 Once 类型用来保证多协程的初始化的安全。多个 Go协程 可以并发执行 once.Do(f) 来执行函数 f, 且只会有一个 Go协程 会运行 f(),其他的 Go 协程会阻塞到那单次执行的 f() 的返回。

规则一:函数 f()once.Do(f) 的单次调用返回发生在其他所有的 once.Do(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()
}

上面的代码中 twoprint 函数只会调用一次 setup 函数。函数 setup 函数的执行返回发生在所有的 print 调用之前,同时会打印出两次 hello, world

不正确的同步方式

对某个变量的读操作 r 一定概率可以观察到对同一个变量的并发写操作 w,但是即使这件事情发生了,也并不意味着发生在 r 之后的其他读操作可以观察到发生在 w 之前的其他写操作。

比如下面的代码:

var a, b int

func f() {
	a = 1
	b = 2
}

func g() {
	print(b)
	print(a)
}

func main() {
	go f()
	g()
}

上面的代码里函数 g 可能会先打印 2(b的值),然后打印 0(a的值);可能大家会认为既然 b 的值已经被赋值为 2 了,那么 a 的值肯定被赋值为 1 了,但事实是两个事件的先后在这里是没有办法确定的。

上面的事实可以证明下面的几个常见的错误。

错误一:双重检查锁定

双重检查锁定是一种尝试避免同步开销的尝试。比如下面的例子,twoprint 函数可能会被错误地编写为:

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()
}

doprint 函数中,观察到对 done 的写操作并不意味着能够观察到对 a 的写操作。应该注意,上面的写法依然有可能打印出空字符串而不是“hello, world”字符串。

错误二:某个值的循环检查

另一个常见的错误用法是对某个值的循环检查,比如下面的代码:

var a string
var done bool

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

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

和上一个例子类似,main函数中观察到对 done 的写操作并不意味着可以观察到对 a 的写操作,因此上面的代码依然可能会打印出空字符串。更为恐怖的,由于两个 Go协程 之间缺少同步事件,main 函数甚至可能永远无法观察到对 done 变量的写操作,导致 main 中的 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 函数观察到 g != nil并且退出了它的 for 循环,依然没有办法保证它可以观察到被初始化的 g.msg 值。

避免上面几个错误用法的方式是一样的:显式使用同步语句。