Go 官方文档中专门介绍了 Go 的内存模型,很多读者第一次接触这个概念时会有误解,以为它是指 Go 对象的内存分配、内存回收和内存整理的规范。其实不是,它描述的是并发环境中多个 goroutine 读取相同变量时,对变量可见性的保证。具体来说,就是指在什么条件下,一个 goroutine 在读取一个变量的值时,能够看到其他 goroutine 对这个变量进行的写的结果。
由于 CPU 指令重排和多级缓存的存在,保证多核访问同一个变量变得非常复杂。毕竟,不同 CPU架构(x86/AMD64、ARM、Power等)的处理方式是不一样的,再加上编译器的优化也可能对指令进行重排,所以编程语言需要一个规范来明确多个线程同时访问同一个变量的可见性和顺序。在编程语言中,这个规范被称为内存模型。
为什么编程语言要定义内存模型呢?主要有两个目的:一是向广大的程序员提供一种保证,以便他们在进行设计和开发程序时,面对同一个数据同时被多个 goroutine 访问的情况,可以做一些串行化访问控制,比如使用 channel 或者 sync 包和 sync/atomic 包中的同步原语;二是允许编译器和硬件对程序进行一些优化,这一点其实主要是为编译器开发者提供的保证,这样可以方便他们对 Go 的编译器进行优化。
Go 的内存模型规范很早就发布了,但是其中还有一些模糊的地方,比如 atomic 的内存模型。 Russ Cox 在 2021 年 6 月专门写了三个文档,探讨计算机内存模型的历史和现状,提出要对 Go 的内存模型进行修订。 2022 年,Go 1.19 中新的 Go 内存模型规范正式发布了。
1. 指令重排和可见性的问题
由于指令重排,代码并不一定会按照你写的顺序执行。
例如,当两个 goroutine 同时对一个变量进行读 / 写时,假设 goroutine g1 对这个变量进行写操作 w, goroutine g2 同时对这个变量进行读操作 r,那么: 如果 g2 在执行读操作 r 时,已经看到了 g1 写操作 w 的结果,则并不意味着 g2 能看到 w 之前的其他写操作。这是一个反直觉的结果,不过的确可能会存在。
接下来,我们看一个反直觉的例子,来感受一个指令重排以及多核 CPU 并发执行导致程序的运行顺序和代码的书写顺序不一样的情况。代码如下:
var a,b int func f(){ a = 1 // w之前的写操作 b = 2 // 写操作w } func g(){ print(b) print(a) } func main(){ go f() //g1 go () //g2 }
可以看到,print(b) 是要打印 b 的值。需要注意的是,即使这里打印出的值是 2,也依然可能在打印 a 的值时,打印出初始值 0, 而不是 1。这是因为:程序在运行时,不能保证 g2 看到的 a 和 b 的赋值有先后关系。
程序在运行时,两个操作的顺序可能不会得到保证,那该怎么办呢?下面我们来了解下 Go 内存模型中很重要的一个概念:happens before,它是用来描述两个时间的顺序关系的。如果某些操作能提供 happens before 关系,那么就可以 100% 保证它们之间的顺序。
2. sequenced before、synchronized before 和 happens before
通常,内存模型描述了程序运行的需求,程序的运行由 goroutine 的运行组成,而 goroutine 又由内存操作构成。
内存操作由以下 4 个细节构成:
- 类型:指示内存操作是普通数据读取、普通数据写入,还是同步操作,如原子操作数据访问、互斥操作或 channel 操作。
- 内存操作大程序中的位置。
- 内存操作正在访问的内存位置或变量。
- 内存操作所读取或写入的值。
有些内存操作是类似于读(read-like)的操作,包括读取、原子读取、互斥加锁和 channel 接收;有些内存操作则是类似于写(write-like) 的操作,包括写入、原子写入、互斥解锁、channel 发送和 channel 关闭;也有些内存操作,如 atomic compare-and-swap, 既类似于读操作,又类似于写操作。
一个 goroutine 的运行被建模为由单个 goroutine 执行的一组内存操作组成。
那么,Go 程序运行的需求可以被归纳为
- 需求1:程序中会有对变量和内存地址的修改,从 goroutine 的视角来看,代码的执行效果必须和代码的执行顺序是一致的。尽管 CPU 执行代码时可能会做调整,但是最终的执行效果必须和代码无调整时的执行效果是一样的。我们把这种关系定义为 sequenced before。先前的 Go 内存模型被统一叫作 happens before, 新版本的 Go 内存模型做了细化,分成 happens before 和 synchronized bofore 两种。
- 需求2:对于给定的一个程序的运行,如果映射关系 W 只考虑同步操作,那么必须可以通过其中某个隐含的总序来解释同步操作,这个总序必须与操作的顺序和读/写操作的值一致。
synchronized before 是同步内存操作上的部分序关系,如果一个同步读取内存操作 r 观察到一个同步写入内存操作 w (W(r) =w),则称 w synchronized before。