【外文技术文档】-Go内存模型

173 阅读4分钟

译文链接

简介 Go的内存模型保证在一个goroutine中可以看到另一个goroutine中对于相同变量的修改

建议

我们需要将多个goroutine的访问串行化当它们修改同一个数据时,为了串行化多个goroutine的访问,我们可以通过channel或者其他的同步原语例如sync包,sync/atomic来保护数据

如果你要阅读这篇文档的余下部分并以此来分析你项目中的一些操作,那么你就太聪明了

不要自以为是😜

Happens Before

对于单个的goroutine来说,对于数据的读和写应该按照程序的定义来依次执行。然而编译器和处理器可能会对程序中读和写的顺序进行重排当这样的重排并不会改变原先程序的语义时,正是因为这样的重排,在另一个goroutine中观察到的执行顺序可能和程序原本的定义顺序不一致,例如,如果在一个goroutine中定义a = 1; b = 2,另一个goroutine可能观察到的执行顺序为b = 2; a = 1,但是这样的重排并不会影响程序原先的语义

针对数据读取和写入的需求,我们定义了happens before,一个在Go程序中执行内存操作的局部顺序。如果事件e1e_{1}在事件e2e_{2}之前发生,那我们说e2e_{2}e1e_{1}之后发生,当然,如果e1e_{1}既不在e2e_{2}之前发生,也不在e2e_{2}之后发生,那么我们就可以说e1e_{1}e2e_{2}是并行的

在一个goroutine中,happens-before的顺序就是程序所表达的顺序

对于一个变量vv的读操作rr可以看到对于该变量的写操作ww需要满足下面两个条件:

  1. rr不在ww之前发生
  2. 没有其它写操作ww发生在ww之后,rr之前

为了保证对于一个变量vv的读操作rr看到特定的写操作ww,需要确保wwrr唯一可以看到的写操作。也就是说rr可以保证看到ww需要满足下面两个条件:

  1. wwrr之前发生
  2. 其它的对于共享变量vv的所有写操作要么发生在ww之前,要么发生在rr之后

下面这对限制就要比上面的限制严格一些,它要求没有其它的写操作与wwrr同时发生

对于一个goroutine来说,没有并发操作,所以上面的两组限制是等价的: 对于变量vv的读操作rr观察到对于该变量vv的最近的一次写操作。当有多个goroutine访问一个共享变量vv时,必须使用同步事件去构建happens-before,确保读操作可以观察到想要的写操作

对于一个变量的初始化在内存模型中被定义为一种写操作

读和写大于一个machine work的操作并不是一个原子操作,不会按照特定的顺序执行

同步机制

初始化

程序的初始化在单个goroutine中,但是这个goroutine可能会创建其它的goroutine

如果package p导入了package q,那么package q中的初始化函数先于package p中的初始化函数执行

main.main的执行是在所有的init函数执行完之后

goroutine 创建

go关键字开启一个goroutine发生在这个goroutine开始执行之前,例如,下面的这个程序:

var a string

func f() {
	print(a)
}

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

调用hello将会打印"hello, world"在将来的某个时间点(可能是hello函数返回之后)

goroutine销毁

一个goroutine的销毁不能保证发生在程序的任何事件之前,例如下面的这个程序:

var a string

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

对于a的赋值没有使用任何的同步事件,所以是不能保证可以被其它的goroutine观察到的。事实上,一个激进的编译器可能会删掉整个go的那一行代码

如果一个goroutine的影响必须被其它的goroutine观察到,使用同步原语例如lock或者channel通信去构建一个相关的执行顺序

channel 通信

channel通信是两个goroutine间同步的主要方式,对于channel的每一次写入都会有与之相对应的一次读出,通常是在不同的goroutine中

对于channel的send操作发生在从该channel中读取数据完成之前

这个程序:

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的写操作发生在向c中发送数据之前,同时也是发生在从c中读取数据完成之前,也就是print函数之前

对于一个channel的关闭发生在从该channel中获取零值之前

在之前的例子中,将c <- 0替换成close(c)同样可以保证打印出"hello, world"

从一个没有缓冲区的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)
}

同样也是保证打印出"hello, world"的。对于a的赋值发生在从c接收数据之前,同时发生在向c发送数据完成之前,也就是print函数之前

如果channel是带缓冲区的(例如: c = make(chan int, 1)),那么这个程序将不能保证打印出"hello, world"(它可能会打印出空字符串,或者做一些其它的事情)

第k个从一个容量为C的channel中读取数据的操作发生在第k + C个向该channel发送数据的操作完成之前

这个规则衍生出之前对于带缓冲区channel的规则。通过channel限定一个计数的信号量;channel中元素的数量决定了当前可以处于活跃态的goroutine数量,channel的容量限定了同一时刻最多活跃的goroutine数量,向channel中发送一个数据来增加信号量,从channel中接收数据后释放信号量,这是一个常见的限制并发数量的做法

下面的这段程序开启一个goroutine对于work中的每一个实体,同时使用一容量为3的channel来限制同一时刻最多执行w函数的数量

var limit = make(chan int, 3)

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

Locks

sync包中实现了两种锁类型,sync.Mutex和sync.RWMutex

不管是sync.Mutex还是sync.RWMutex,对于Unlock操作是发生在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)
}

可以保证打印出hello, world。对于Unlock的调用在第二次Lock操作之前,也就是在print函数执行前

Once

sync包提供了一个安全的原语Once应对多个goroutine同时初始化的场景。多个goroutine可以同时执行once.Do(f),但是只会有一个执行f(),其它的goroutine都会阻塞直到f函数返回

对于单个goroutine执行once.Do(f),进而执行f()的场景发生在其它调用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函数将在print函数执行之前完成,所以对于每一个goroutine来说都会打印"hello, world"

不正确的同步

提醒下一个读操作rr可能观察到与它同时发生的写操作ww修改的值,即使这个事情发生了,也并不意味着发生在rr之后的读操作一定可以观察到在ww之前的写操作 在这个程序中:

var a, b int

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

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

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

g()可能会打印2和0

双重检查机制是一种避免上面Once的例子中同步操作的尝试。下面这个情况,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)
}

和上面的程序一样,没有任何的保证观察到对于done的写操作意味着观察到对于a的写操作,所以这个程序也可能会打印空字符串。更糟的是,没有保证main函数可以观察到对done的写操作,因为在两个线程间没有同步事件,main函数中的这个循环不能保证正常退出

对于上面的这个问题还有变体,例如下面这段代码:

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并且结束循环,也没有保证可以观察到对于g.msg的初始化操作

在所有的这些例子中,解决方案是相同的: 使用明确的同步操作

小结

这篇文章阐述了Go内存模型中一个比较重要的概念: happends-before的语法,从这篇文章中我学习到了由于可能存在的编译器/处理器优化,所以在我们程序中定义的一些执行顺序可能会被重排,需要使用同步事件保证当前的读操作获得它想要的结果,当然翻译中如果有有问题的地方,欢迎大家在评论区指出