LJ的Blog

学海无涯苦做舟

0%

Go 内存模型(翻译)

原文:The Go Memory Model

约定:

  • 普通的小括号是我认为原文应该要加入括号中翻译的,属于原文的一部分,也有部分我觉得可能翻译的不够准确会放入英文原文
  • 小括号中带 ta(translator add) 的表示译者认为应该添加可以帮助更好的理解的
  • 某些单词可能不翻译更有原来的味道,比如 channel,也没必要一定要翻译成中文通道

The Go Memory Model

2014年3月的版本

介绍

Go 内存模型指定了以下情形可以被保证:在一个协程中读取一个变量可以观察到由不同协程写入同一个变量所产生的值。

建议

由多个协程同时修改数据的程序必须串行(ta:serialize,个人理解应该是串行而不是序列化)这种访问。

为了串行访问,使用 channel 操作或者其他同步原语(例如在 sync 和 sync/atomic 包中的)保护数据。

如果您为了理解您的程序行为(ta: 协程并发行为)一定要阅读此文档的剩余部分,那您真的是太明智了。

不要耍小聪明(Don’t be clever)。

Happens Before

在单个协程中,读和写必须表现得和由程序指定的执行顺序一样。也就是说,编译器和处理器只会在重新排序不会改变在语言规范中定义的协程行为时才会重排单个协程中执行的读写。由于这种重排,一个协程观察到的执行顺序可能和另一个感知到的顺序不同。例如:一个协程执行了 a = 1; b = 2; 另一个协程可能观测到的是 b 在 a 之前先更新了值。

为了指定读和写的要求,我们定义了 happens before,一个在 Go 程序中内存操作执行的偏序(partial order)。如果事件 e1 在事件 e2 之前发生(e1 happens before e2),那我们就说 e2 发生在 e1 之后(e2 happens after e1)。同样的,e1 既没有发生在 e2 之前也没有发生在 e2 之后,那么我们就说 e1 和 e2 是同时发生的(happen concurrently) 。

在单个协程中,happens-before 顺序就是程序表达的顺序。

如果以下两项都成立,那么对变量 v 的读取 r 允许观察到对 v 的写入 w:

  • r 没有在 w 之前发生
  • 没有其他对 v 的写 w’ 发生在 w 之后但是在 r 之前

为了保证对变量 v 的读取 r 可以观察到对 v 的特定写入 w,确保 w 是唯一的被 r 允许观测的写。也就是说,如果以下两项都成立,则保证 r 可以观测到 w:

  • w 在 r 之前发生
  • 任何其他对共享变量 v 的写入要么发生在 w 之前要么在 r 之后

这对条件要比上面那对条件要强。他要求没有其他写入与 w 或 r 同时发生。

在单个协程中,没有并发,所以两个定义是等价的:读(ta: 操作)r 观察到最近一次对于 v 的写(ta: 操作)w 的写入值。当多个协程访问一个共享变量 v 时,必须使用同步事件来建立 happens-before 条件来确保读能观察到想要的写。

使用 v 类型的零值初始化变量 v 表现为在内存模型中的写入。

读取和写入值大于一个机器字长表现为多个未指定顺序的机器字长操作。

同步

初始化

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

如果 a 包 p 导入了包 q,q 的 init 函数(ta: 调用)完成发生(happens before)在 p 的任何启动之前。

main.main 函数的启动发生在所有 init 函数完成之后。

协程创建

启动新协程的语句 happens before 协程开始执行。

例如,在这个栗子中:

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 函数已经返回之后)打印 “hello, world”

协程销毁

协程的退出不保证 happen before 任何在这个程序中的事件。例如,在这段程序中:

1
2
3
4
5
6
var a string

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

对 a 的赋值之后没有任何同步事件,所以不保证可以被任何其他协程观察到。事实上,激进的编译器可能删除整个 go 声明。

如果一个协程的影响必须被另一个协程观测到,使用同步机制如 lock 或 channel communication 去建立相对顺序。

Channel 通讯(Channel communication)

Channel communication 是两个协程间同步的主要方法。通常在不同的协程中,每一个特定 channel 上的发送都与该 channel 上相应的接收匹配。

channel 上的发送 happens before channel 上相应的接收完成。

如下程序:

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 的写入 happens before 在 c 上发送,(ta: 前面俩)happens before 在 c 上相应的接收完成,(ta: 前面仨)happens before print (ta: 调用)。

关闭 channel happens before 收到返回 0 值,因为 channel 已经关闭了。

在前一个列子中,将 c<- 代替为 close(c) 产生具有相同保证行为的程序(ta: 是指 print(a) 无法观测到 a 被赋值了,因为 channel 已经被关闭了)。

来自无缓冲 channel 上的接收 happens before 在该 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 的写入 happens before c 上的接收,(ta: 前面俩)happens before c上相应的发送完成,(ta: 前面仨)happens before print(ta: 函数调用)。

如果 channel 是有缓冲的(例如,c = make(chan int, 1)),那么程序不会保证打印 “hello, world” 。(也许会打印空字符串,崩溃,或者做其他事。)

容量为 C 的 channel 上第 k 个接收 happens before 在该 channel 上第 k+C 个发送完成。

此规则将之前的规则推广到带缓冲的 channel。它允许通过带缓冲的 channel 对计数信号量建模:该 channel 中的 item 数量对应活跃使用的数量,channel 的容量对应同时使用的最大数量,发送一个 item (ta: 需要)获取信号量,收到一个 item 需要释放信号量。这是限制并发的通常做法(This is a common idiom for limiting concurrency.)。

这段程序为 work list 中的每一个条目启动了一个协程,但是协程使用 limit channel 进行协调以确保一次最多运行三个 work 函数。

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{}
}
锁(Locks)

sync 包实现了两种锁数据类型:sync.Mutex 和 sync.RWMutex。

对于任何 sync.Mutex 或 sync.RWMutex 变量 l 且 n < m,对 l.Unlock() 的 n 次调用 happens before 对 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” 。第一次调用 l.Unlock(在 f 中) happens before 第二次调用 l.Lock()(在 main 中)返回,(ta: 前面俩)happens before print(ta: 函数调用)。

对于任意调用 sync.RWMutex 变量 l 的 l.RLock,有 n,l.Rlock() happens (returns) after 调用 n 次 l。Unlock 和匹配的 l.RUnlock happens before 调用 n+1 次 l.Lock。

Once

sync 包提通过使用 Once 类型(ta: 为以下情况)提供一种安全的机制:存在多个协程时的初始化。多线程可以为特定的 f 执行 once.Do(f),但是只有一个(ta: 线程)会执行 f(),其他的直到 f() 返回前调用都会阻塞。

once.Do(f) 的单次调用 happens (returns) before 任何调用 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 只会调用 setup 一次。setup 函数将在任何一个 print 调用之前完成。结果是 “hello, world” 会被打印两次。

不正确的同步(Incorrect synchronization)

请注意,读 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 打印 2 然后打印 0。

这一事实使一些常见的惯用语无效。

双重检查锁(Double-checked locking)是尝试避免同步开销的一种尝试。举个栗子,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”。

另一个不正确的惯用语是忙于等待(is busy waiting for)一个值,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 会观察到对 done 的写入,因为两个线程之间没有同步事件。main 中的循环不保证会结束(ta: 死循环)。

这个主题有更微妙的变种,比如这个程序。

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 的初始化值。

在所有这些栗子中,解决方式是一样的:明确的使用同步(use explicit synchronization)