Kyle's Notebook

Go 内存模型(译)

Word count: 2.5kReading time: 9 min
2021/07/31

原文链接:The Go Memory Model

Go 内存模型

Go 内存模型规定了 在一个 goroutine 中读取变量时,可观察到在不同 goroutine 中对该变量进行写入所产生的值 的条件。

建议

在一个程序中,多个 goroutine 同时修改同一份数据,则必须将访问操作串行化。

要实现串行化访问,请使用 Channel 操作或其它同步原语(比如 sync 和 sync/atomic 中提供的)保护数据。

先行发生规则

在单个 goroutine 中,读写操作必然是按程序中的顺序执行。即编译器和处理器可能会对单 goroutine 中的读写操作进行重排序,当且仅当重排序不会改变语言规范中定义的 goroutine 行为。由于存在重排序,在一个 goroutine 中观察到的执行顺序可能与其它 goroutine 所感知到的不同。比如在一个 goroutine 中执行 a = 1; b = 2,其它 goroutine 可能在 a 的值更新前就观察到 b 的更新值。

为指定对读写操作的要求定义了 先行发生规则(happens-before,一种在 Go 程序中执行内存操作的偏序):如果事件 e1 发生在事件 e2 之前,则认为 e2 发生在 e1 之后。另外如果 e1 不是发生在 e2 之前,也不是发生在 e2 之后,则认为 e1 和 e2 并发。

在单个 goroutine 中,happens-befors 顺序就是程序代码所表达的顺序。当以下两项成立,允许对变量 v 的读操作 r 观察到写操作 w:

  • r 不先行发生于 w。

  • 在 w 之后、r 之前,没有其它对 v 的写操作 w’。

为了保证对变量 v 的读操作 r 观察到写操作 w,请确保 w 是 r 允许看到的唯一写操作。即当以下两项都成立,能保证 r 可观察到 w:

  • w 先行发生于 r。

  • 其它对共享变量的写操作只在 w 之前或 r 之后。

这组条件比第一组更严格,其要求没有其它的写操作与 w 和 r 并发执行。

在单个 goroutine 中不存在并发,所以这两个定义是等价的:读操作 r 观察到最近一次写操作 w 所写入 v 的值。当多个 goroutines 访问同共享变量 v 时,它们必须基于同步事件来建立 happens-before 条件,来确保读操作能观察到其所期望的写操作。

把变量 v 初始化为其类型的零值,表现如同在内存模型中的写操作。

对大于单机机器字的变量的读写操作,表现如同多个未指定顺序的、对机器字大小变量的操作。

比如 interface 的底层是两个字段:

1
2
3
4
type interface struct {
Type uintptr
Data uintptr
}

在执行赋值操作(比如 var maker IceCreamMaker = ben)时,实际上是对两个机器字的操作,存在 data race,因此要使用同步原语保证线程安全。

同理还有 map、slice,直接赋值也是不能确保线程安全的。

同步

初始化

程序初始化在单个 goroutine 中运行,但该 goroutine 可能会创建其他并发运行的 goroutine。

如果包 p 导入包 q,则 qinit 函数的完成先行发生于 p 的开始。

所有 init 函数的完成先行发生于 mainmain 函数的开始。

Goroutine 创建

启动 goroutine 的 go 语句先行发生于 goroutine 的开始。比如以下程序:

1
2
3
4
5
6
7
8
9
10
var a string

func f() {
print(a)
}

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

调用 hello 将在未来的某个时刻打印(可能在 hello 返回之后)。

Goroutine 销毁

不能保证 goroutine 的退出先行发生于任何事件。比如以下程序:

1
2
3
4
5
6
var a string

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

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

要使一个 goroutine 操作效果必须被另一个 goroutine 观察到,请使用同步机制,例如锁或信道通信来建立相对顺序。

Channel 通信

Channel 通信是实现 goroutine 之间同步的主要方式。特定 Channel 上的每个发送都与来自该 Channel 的接收相匹配,而且通常发生在不同的 goroutine 中。

以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
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 中接收内容的完成,先行发生于打印 a

Channel 的关闭,先行发生于一个(由于信道关闭而)返回零值的接收操作。

在前面的示例中,如果将 c <- 0 替换为 close(c),会产生具有同样保证行为的程序。

从无缓冲 Channel 接收先行发生于往该 Channel 写入数据的结束。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 发送内容的完成,先行发生于打印 a

如果 Channel 是缓冲的(比如 c = make(chan int, 1)),则程序将不能保证打印“hello, world”(可能打印空字符串、崩溃或其它)。

在容量为 C 的 Channel 上,第 k 个接收操作先行发生于第 k+C 个发送操作的完成。

此规则将之前的规则推广到缓冲 Channel。它允许通过缓冲 Channel 对计数信号量进行建模:Channel 中的记录数对应于活跃使用数,Channel 的容量对应于同时最大使用数,发送记录获取信号量,以及接收一条记录释放信号量。 这是限制并发的常见用法。

该程序为任务列表中的每个条目启动一个 goroutine,但是 goroutine 使用限制 Channel 进行协调,以确保一次最多有三个同时运行的工作函数。

1
2
3
4
5
6
7
8
9
10
11
12
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,变量 ln < ml.Unlock() 调用 n 先行发生于 l.Lock() 调用 m 的返回。

以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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”。在 f 中调用 l.Unlock() 先行发生于在 main 中调用 l.Lock() 的返回,先行发生于打印 a

对于在 sync.RWMutex 变量 l 上对 l.RLock 的任何调用,都存在一个 n,使得调用 nl.Unlock 的返回先行发生于 l.RLock ,且与之匹配的 l.RUnlock 先行发生于调用 n+1l.Lock

这句话有点难理解,读了好几遍都不知道怎么翻译。

Once

对于存在多个 goroutine 的场景,sync 包为提供了一种通过 Once 类型实现的安全初始化机制。对于特定的函数 f,多个线程可以执行 once.Do(f),但只有其中的一个会执行 f(),而其它的调用会阻塞,直到 f() 返回。

这个来自 once.Do(f)(成功调用的)的 f() 的返回,先行发生于其它对 once.Do(f) 调用的返回。

在以下代码中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 时将只会调用一次 setupsetup 函数将在任一 print 调用之前完成。结果是“hello, world”被打印两次。

错误的同步

注意,一个读操作 r 可能观察到被(与 r 并发进行的)写操作修改的变量 w。即使这种情况发生,也不意味着在 r 之后发生的读操作可观察到发生在 w 之前的写操作。

在以下代码中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var a, b int

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

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

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

g 可能会打印 0 或 2。事实证明一些常见用法是不对的。

双重校验锁是一种尝试避免同步开销的做法。比如下面代码中的 twoprint 是错误的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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”。

另一种错误的常见用法是一直等待一个值(的变化),比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
var a string
var done bool

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

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

main 中能观察到对 done 的写入,也不能保证就意味着能观察到对 a 的写入,因此以上代码也可能输出空串而不是“hello, world”。更糟糕的是这两个线程之间没有同步,无法保证 main 一定能观察到对 done 的写入,可能导致死循环。

以下是更微妙的变体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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 的初始化值。

在这些示例中,解决方法都是一样的:使用显式同步。

CATALOG
  1. 1. Go 内存模型
    1. 1.1. 建议
    2. 1.2. 先行发生规则
    3. 1.3. 同步
      1. 1.3.1. 初始化
      2. 1.3.2. Goroutine 创建
      3. 1.3.3. Goroutine 销毁
      4. 1.3.4. Channel 通信
      5. 1.3.5.
      6. 1.3.6. Once
    4. 1.4. 错误的同步