Go Memory Model

511 阅读9分钟

1. 前言

在上篇文章 Hardware Memory Models 中介绍了不同架构的CPU Memory Model。在这篇文章中,将介绍 Go 语言的 Memory Model。本篇文章主要内容来自于 Go 的官方博客 The Go Memory Model,在原始文章中有一些不懂的概念,经过查阅资料也进行了一些解释,如果有兴趣也可以参阅引用资料。

2. Memory Model

2.1 定义

在 go 语言中 memoy model 的定义如下:

The memory model describes the requirements on program executions, which are made up of goroutine executions, which in turn are made up of memory operations.

翻译过来就是:内存模型描述了程序执行的需求。程序执行由 goroutine 执行组成,而 goroutine 执行又由内存操作组成。简而言之就是内存模型描述了内存操作的规范

内存操作的定义如下:

  • 内存操作的类型,

    • 原始数据读
    • 原始数据写
    • 同步操作
      • 原子数据访问,
      • 互斥操作
      • channel operation。
  • 内存操作地址

  • 正在访问的内存地址或者变量

  • 该内存操作读或者写的值 可以将内存操作分为三大类:

  • read-like

    • read
    • atomic read
    • mutex lock
    • channel receive
  • write like

    • write
    • atomic write
    • mutex unlock
    • channel send
    • channel close
  • both read-like and write-like

    • atomic compare-and-swap

2.2 Requirement

2.2.1 sequenced before

goroutine execution is modeled as a set of memory operations executed by a single goroutine.

翻译就是:一个 goroutine 的执行可以被模型化为:在单一 goroutine 上执行的内存操作集合。

每一个 goroutine 中的内存操作必须符合该 goroutine 的正确的执行顺序,goroutine 的值行必须符合 sequenced before relationsequenced before relationorder of evaluation for expressions 中定义。

我们首先来了解下什么是 sequenced before,在了解什么是 sequenced before 之前,首先来了解什么是 evaluation (求值)。 求值包含两件事情:

  1. value computations 对表达式的计算结果
  2. side effect 对内存状态的更改,调用 library 的 I/O function 等等。

在 c++ 中,并没有定义操作数的求值顺序,比如: f1() + f2() + f3()

在上述表达式中,f1(),f2() 和 f3() 都是运算数。

编译器可以任意决定哪个运算数先计算,也即函数调用,然后再按照 + 运算符的运算规则从左往右依次进行运算。 所以现在可以明确:在 Go 中,sequeced-before 就是在同一 goroutine 中,求值顺序的描述:

  • 如果 A is sequenced before B,代表 A 的求值会先完成,才进行对 B 的求值
  • 如果 A is not sequenced before B 而且 B is sequenced before A,代表 B 的求值会先完成,开始对 A 的求值。
  • 如果 A is not sequenced before B 而且 B is not sequenced before A,有两种可能,
    • 一种是顺序不确定,甚至這两者的求值过程可能會重叠(因為CPU优化指令交错的关系)
    • 不重叠。

Go memory model 就是要在语言层面定义 sequenced before 规则,详细内容可以参阅 order of evaluation for expressions

2.2.2 synchronized before

A Go program execution is modeled as a set of goroutine executions, together with a mapping W that specifies the write-like operation that each read-like operation reads from. (Multiple executions of the same program can have different program executions.)

翻译就是:一个 go 程序的执行可以被模型化为:goroutine 的执行集合,并且伴随着从 read-like 操作到 write-like 的映射 W。(多次执行同一个程序能够拥有不同的程序执行路径)

For a given program execution, the mapping W, when limited to synchronizing operations, must be explainable by some implicit total order of the synchronizing operations that is consistent with sequencing and the values read and written by those operations.

翻译就是:对于给定的程序执行,当限于同步操作时,映射 W 必须可以通过与排序一致的同步操作的某种隐式总顺序以及这些操作读取和写入的值来解释。(好吧我承认没太看懂。。。。。。)

我们可以首先来了解什么是 synchronized before

synchronized before relation 是 synchronizing memory operations 的偏序,派生自 W。 当一个同步的 read-like 操作 r 能够观察到同步的 write-like 内存操作 w(也就是如果 W(r) = w),所以 w synchronized before r

上面是 Go 官方 博客 中翻译的一句话,可以先接着看下一小节的 happen before。

2.2.3 happen before

Let A and B represent operations performed by a multithreaded process. If A happens-before B, then the memory effects of A effectively become visible to the thread performing B before B is performed.

这是由 Jeff Preshing所提供的解释,翻译过来就是:让 A 和 B 表示多线程进程执行的操作。如果 A happens-before B,那么 A 产生的内存效应在执行线程 B 之前,对 B 的线程可见。

Happens-before 强调的是 visible,而不是实际上执行的顺序。考虑如下代码:

int A = 0;
int B = 0;

void foo()
{
    A = B + 1;              // (1)
    B = 1;                  // (2)
}

int main()
{
    foo();
}

如果输入命令:gcc file.c,产生的汇编命令如下:

movl    B(%rip), %eax
addl    $1, %eax
movl    %eax, A(%rip)
movl    $1, B(%rip)

可以看到先把 B 放到 eax,之後 eax+1 放到 A,然後才执行 B = 1。

但是输入命令:gcc -O2 file.c,则会产生如下的汇编命令。

movl    B(%rip), %eax
movl    $1, B(%rip)
addl    $1, %eax
movl    %eax, A(%rip)

现在变成:先执行 B 放到 eax,然后执行 B = 1,最后再执行 eax+1 ,再把结果存到 A。B 比 A 更早完成。

但这有违反 happens-before 的关系吗?答案是没有,因为 happens-before 只关注是否看起来有这样的效果,从外界看起来,就仿佛是先执行第一行,完成之后,再执行第二行。

因此我们学到了一个重要的关键,A happens-before B 并不代表实际上 A happening before B。关键在于只要 A 的效果在 B 执行之前,对于 B 可见就可以了,实际上怎么执行的并不需要深究。

Go 官方博客 定义的 happen before 如下:

The happens before relation is defined as the transitive closure of the union of the sequenced before and synchronized before relations.

翻译就是:happens before 关系被定义为 sequenced before 和 synchronized before 关系联合的传递闭包。

主要理解在于什么叫传递闭包,详情见:transitive closure,可以见下面的例子:

假设有 A 和 B 两个 goroutine。其操作如下:

共享变量

m := new(Mutex)
a := 0
b := -1

gorutine A

b = 1    // 0
m.lock() // 1
a := 3;  // 2
m.unlock // 3

gorutine B

m.lock()      // 4
fmt.printf(a) // 5
m.lock()      // 6

在 goroutine A 中,操作 0 sequenced before 操作 2,

假设 goroutine A 先加锁成功,则操作写操作 2 synchronized before 读操作 5。 则可以认为操作 0 happen before 操作 5。

对于在内存位置 x 上的 普通(non-synchronizing)数据读取操作 r,W(r) 映射的 w 必须 对于 r 是可见的,那么如下两个条件必须成立:

  1. w happens before r.
  2. w does not happen before any other write w'  (to x) that happens before r.

3. Synchronization

3.1 Initialization

  • 如果 package p 引入了 package q,那么 q 的 init 函数的执行完成一定happen-before p 的所有 init 函数(之前)
  • main.main 函数一定 happen after 所有的 init 函数完成(之后)

3.2 Goroutine creation

  • go 语句创建一个 goroutine 一定 happen before 新建的 goroutine 开始执行(之前)

比如下面这个程序。

var a string

func f() {
	print(a)
}

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

调用 hello 函数将在将来的某个时刻打印 “hello, world” (可能在 hello 函数返回之后)。

3.3 Goroutine destruction

不能保证 goroutine 的退出 synchronized before 任何程序中的事件。例如,在这个程序中:

var a string

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

给 a 赋值后没有任何同步事件,因此不能保证其他 goroutine 会观察到它。事实上,编译器可能会删除整个go语句。

如果一个 goroutine 的效果必须由另一个 goroutine 观察,则使用同步机制(如锁或通道通信)来建立相对顺序。

3.4 Channel communication

信道通信是实现同步的主要方法。一个通道上的每个发送都与该通道的相应接收相匹配,并且发送和操作操作发生在不同的 goroutine 中。

  • 往一个 channel 中 send happen before 从这个 channel receive 这个数据完成(之前)
  • 一个 channel 的 close 一定 happen before 从这个 channel receive 到零值数据(这里指因为close而返回的零值数据)
  • 从一个 unbuffered channel 的 receive 一定 happen before 往这个 channel send 完成(之前)
  • 从容量为 C 的 channel receive 第 k 个数据一定 happen before第 k+C 次 send 完成(之前)

3.5 Locks

    • 对于任意的 sync.Mutex 或者 sync.RWMutex 类型的变量 l 以及 n < m, 调用第 n 次 l.UnLock() 一定 happen before 第 m 次的 l.Lock() 返回(之前)

3.6 Once

  • once.Do(f) 中的对 f 的单次调用一定 happen before 任意次的对 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()
}

调用 twopprint 只会调用 setup 一次。setup 函数将在任何一次 print 函数调用之前完成。结果是 “hello, world” 将被打印两次。

3.7 Atomic Values

sync/atomic 包中的 api 统称为“原子操作”,可用于同步不同 goroutine 的执行。如果原子操作 B 观察到原子操作 A 的效果,则 A is synchronized before B。程序中执行的所有原子操作的行为就好像是按照某种顺序一致的顺序执行的。

前面的定义与c++的顺序一致原子和Java的volatile变量具有相同的语义。

3.8 Finalizers

runtime 包中提供了一个 SetFinalizer 函数,该函数添加了一个 finalizer,当程序无法再访问特定对象时将调用它。对 SetFinalizer(x, f) 的调用 synchronized before 最后一次调用 f(x)。

3.9 Additional Mechanisms

sync 包提供了额外的同步抽象,包括 condition variableslock-free mapsallocation pools, and wait groups。 它们的文档都指定了对同步所做的保证。

其他包提供同步抽象的包也应该描述它们所做的保证。

4. Incorrect synchronization

有 data race 的程序是不正确的,并且表现出非顺序一致的执行行为。需要注意的是,读操作 r 可能会观察到与它同时执行的写操作 w 写入的值。即使发生这种情况,也不意味着发生在读操作 r 之后的读取操作 r‘ 会观察到写操作 w 之前的写操作 w’ 所写入的值。看下面这个程序:

var a, b int

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

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

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

g() 函数可能先打印 2,然后再打印 0。

这个事实使一些常见的习语无效。

Double-checked locking 定是一种避免同步开销的尝试。 例如,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 写操作的观察,所以这个程序也可以打印一个空字符串。更糟糕的是,由于两个线程之间没有同步事件,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 的初始化值。

在所有这些示例中,解决方案都是相同的:use explicit synchronization。

5. Incorrect compilation

Go memory model 对编译器优化的限制和对 Go 程序的限制一样多。一些在单线程程序中有效的编译器优化在所有 Go 程序中都无效。特别地,编译器不能引入原程序中不存在的写操作,不能允许单个读操作观察多个值,也不能允许单个写操作写入多个值。

以下所有示例都假设' *p '和' *q '指的是多个 goroutine 可访问的内存位置。

没有在 race-free 程序中引入 data races 意味着不将写操作 从条件语句中移出。例如,编译器不能在这个程序中反转条件判断语句:

*p = 1
if cond {
	*p = 2
}

也就是说,编译器不得将程序重写为以下程序:

*p = 2
if !cond {
	*p = 1
}

如果 cond 为 false,而另一个 goroutine 例程读取 *p ,则在原程序中,另一个 goroutine 只能观察到 *p 和 1 的任何先前值。在编译器重写的程序中,另一个 goroutine 可以观察 2,这在以前是不可能的。

不引入数据竞争也意味着不假设循环终止。例如,编译器通常不能将 *p 或 *q 的访问移到这个程序的循环前面:

n := 0
for e := list; e != nil; e = e.next {
	n++
}
i := *p
*q = 1

如果 list 指向一个循环列表,那么原来的程序永远不会访问 *p 或 *q,但重写后的程序可以。(如果编译器能证明 *p 不会引发 panic,那么将 *p 移到前面是安全的,向前移动 *q 还需要编译器证明没有其他 goroutine 可以访问 *q。)

不引入数据竞争也意味着不假设被调用的函数总是返回或没有同步操作。例如,编译器不能在这个程序的函数调用之前移动对 *p 或 *q 的访问(至少不能在没有直接了解 f 的精确行为的情况下):

f()
i := *p
*q = 1

如果 f() 函数调用没有返回,那么原程序也不会访问 *p 或 *q,但是重写的程序可以。如果 f() 调用包含同步操作,那么原程序可以建立 happen before 关系,但重写的程序不会。

不允许单个读操作观察多个值,意味着不从共享内存中重新加载局部变量。例如,在这个程序中,编译器不能丢弃 i 并从 *p 中重新加载它:

i := *p
if i < 0 || i >= len(funcs) {
	panic("invalid function index")
}
... complex code ...
// compiler must NOT reload i = *p here
funcs[i]()

如果复杂的代码需要很多寄存器,单线程程序的编译器可以丢弃 i 而不保存副本,然后在 funcs[i]() 之前重新加载 i = *p。Go编译器不能这样做,因为 *p 的值可能已经改变。(相反,编译器可能会将i溢出到堆栈中。)

不允许一次写操作写入多个值意味着:不使用局部变量的值作为写入目的内存的临时值。例如,编译器不能在这个程序中使用 *p 作为临时存储:

*p = i + *p/2

也就是说,它不能把程序重写成这样:

*p /= 2
*p += i

如果 i 和 *p 开始时等于 2,原始代码将 *p = 3,因此一个竞争的线程只能从 *p 读取 2 或 3。重写后的代码先执行 *p = 1,然后执行 *p = 3,允许另一个线程读取 1。

注意,如果编译器能够证明 data races 不会影响目标平台上的正确执行,则禁止引入 data race 将不适用。在下面的例子中,在所有 cpu 上,重写都是有效的。

原程序:

n := 0
for i := 0; i < m; i++ {
	n += *shared
}

重写后的的程序:

n := 0
local := *shared
for i := 0; i < m; i++ {
	n += local
}

前提是可以证明 *shared 不会在访问时出错,因为重写后添加的读取操作不会影响任何现有的并发读取或写入。On the other hand, the rewrite would not be valid in a source-to-source translator.

6. 总结

  • Go memory model 定义了 memory operation rule。

  • sequenced before relation 定义了在同一个 goroutine 中对表达式求值的顺序规定。

  • synchronized before relation 定义了不同的 goroutine 中对同一个 memeory location 同时写或者读写同时存在时,使用的同步工具中定义的规则。

    • Mutex
    • Channel
    • Once
    • Pool
    • Atomic Value
    • Condition Variable
    • RWMutex
    • Finalizers
    • WaitGroup
  • happen before relation 是 sequenced before 和 synchronized before 并集的传递闭包。

  • Go memory model 也规定编译器不能做的一些优化。

7. 引用

  1. The Go Memory Model
  2. Concurrency系列(二): 從Sequenced-Before開始說起
  3. Concurrency系列(三): 朝Happens-Before邁進
  4. Concurrency系列(四): 與Synchronizes-With同在
  5. order of evaluation for expressions
  6. The Happens-Before Relation
  7. [译]更新Go内存模型