原文链接: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
,则 q
的 init
函数的完成先行发生于 p
的开始。
所有 init
函数的完成先行发生于 main
包 main
函数的开始。
Goroutine 创建
启动 goroutine 的 go 语句先行发生于 goroutine 的开始。比如以下程序:
1 | var a string |
调用 hello
将在未来的某个时刻打印(可能在 hello
返回之后)。
Goroutine 销毁
不能保证 goroutine 的退出先行发生于任何事件。比如以下程序:
1 | var a string |
对 a 赋值之后没有任何同步事件,因此不能保证任何其他 goroutine 能观察到它。事实上激进的编译器可能会删除整个 go 语句。
要使一个 goroutine 操作效果必须被另一个 goroutine 观察到,请使用同步机制,例如锁或信道通信来建立相对顺序。
Channel 通信
Channel 通信是实现 goroutine 之间同步的主要方式。特定 Channel 上的每个发送都与来自该 Channel 的接收相匹配,而且通常发生在不同的 goroutine 中。
以下代码:
1 | var c = make(chan int, 10) |
可保证能输出“hello, world”。对 a
的赋值先行发生于往 c
上发送内容,先行发生于从 c
中接收内容的完成,先行发生于打印 a
。
Channel 的关闭,先行发生于一个(由于信道关闭而)返回零值的接收操作。
在前面的示例中,如果将 c <- 0
替换为 close(c)
,会产生具有同样保证行为的程序。
从无缓冲 Channel 接收先行发生于往该 Channel 写入数据的结束。
1 | var c = make(chan int) |
以上代码也能保证输出“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 | var limit = make(chan int, 3) |
锁
sync 包提供了两种锁的实现,sync.Mutex
和 sync.RWMutex
。
对于任何 sync.Mutex
或 sync.RWMutex
,变量 l
且 n < m
,l.Unlock()
调用 n
先行发生于 l.Lock()
调用 m
的返回。
以下代码:
1 | var l sync.Mutex |
可保证能输出“hello, world”。在 f
中调用 l.Unlock()
先行发生于在 main
中调用 l.Lock()
的返回,先行发生于打印 a
。
对于在 sync.RWMutex
变量 l
上对 l.RLock
的任何调用,都存在一个 n
,使得调用 n
去 l.Unlock
的返回先行发生于 l.RLock
,且与之匹配的 l.RUnlock
先行发生于调用 n+1
去 l.Lock
。
这句话有点难理解,读了好几遍都不知道怎么翻译。
Once
对于存在多个 goroutine 的场景,sync 包为提供了一种通过 Once
类型实现的安全初始化机制。对于特定的函数 f
,多个线程可以执行 once.Do(f)
,但只有其中的一个会执行 f()
,而其它的调用会阻塞,直到 f()
返回。
这个来自 once.Do(f)
(成功调用的)的 f()
的返回,先行发生于其它对 once.Do(f)
调用的返回。
在以下代码中:
1 | var a string |
调用 twoprint
时将只会调用一次 setup
。setup
函数将在任一 print
调用之前完成。结果是“hello, world”被打印两次。
错误的同步
注意,一个读操作 r 可能观察到被(与 r 并发进行的)写操作修改的变量 w。即使这种情况发生,也不意味着在 r 之后发生的读操作可观察到发生在 w 之前的写操作。
在以下代码中:
1 | var a, b int |
g
可能会打印 0 或 2。事实证明一些常见用法是不对的。
双重校验锁是一种尝试避免同步开销的做法。比如下面代码中的 twoprint
是错误的:
1 | var a string |
在 doprint
中观察到对 done
的写入,也不能保证就意味着能观察到对 a
的写入,以上代码可能输出空串而不是“hello, world”。
另一种错误的常见用法是一直等待一个值(的变化),比如:
1 | var a string |
在 main
中能观察到对 done
的写入,也不能保证就意味着能观察到对 a
的写入,因此以上代码也可能输出空串而不是“hello, world”。更糟糕的是这两个线程之间没有同步,无法保证 main
一定能观察到对 done
的写入,可能导致死循环。
以下是更微妙的变体:
1 | type T struct { |
即使 main
观察到 g != nil
并退出循环,也不能保证就会观察到 g.msg
的初始化值。
在这些示例中,解决方法都是一样的:使用显式同步。