Go 内存模型
Mar 26, 2022 17:00 · 2183 words · 5 minute read
Go 的内存模型确保一个 goroutine 中读取一个变量时,能观察到不同 goroutine 写入同一变量的值。
建议
多个 groutine 同时修改数据最好串行化。
串行化访问,通过 channel 操作或其他同步原语来保护数据,比如 sync 和 sync/atomic 包中的那些。
Happens Before
在同一个 goroutine 中读和写是按照程序指定的顺序来执行的。也就是说,编译器和处理器只有在重新排序不改变语言规范所定义的 goroutine 内的行为时,才可能重排同一 goroutine 内的读写执行顺序。正因为这种重新排序,一个 goroutine 观察到的执行顺序可能与另一个 goroutine 感知到的不同。举个栗子,如果一个 goroutine 执行 a = 1; b = 2;
,另一个可能在 a
值更新前就观察到 b
值更新。
为了明确读写的要求,我们定义了 happens before,这是一种 Go 程序中执行内存操作的部分顺序。如果事件 e1 在事件 e2 之前发生,同理 e2 也就在 e1 之后发生。如果 e1 即不在 e2 之前发生也不在 e2 之后发生,我们就说 e1 和 e2 是并发的。
在单个 goroutine 中 happens before 顺序就是程序表达的顺序。
如果下面两个条件成立,允许对变量 v
的读取事件 r 观察到对 v
的写入事件 w:
- r 不在 w 之前发生。
- 在 r 之前 w 之后没有其他对
v
的写入。
为了保证 r 读取变量 v
观察到特定的 w 写 v
,确保 w 是 r 被允许观察到的唯一写入。也就是说如果下面两个条件成立 r 就能观察到 w。
- w 在 r 之前发生。
- 任意对共享变量 v 的写入要么在 w 之前要么在 r 之后。
这对条件比第一对更强;它要求没有其他写入与 w 或 r 并发发生。
在单个 goroutine 中当然没有并发,所以两个定义是等价的:读事件 r 观察到由最近一次写事件 w 对 v 写入的值。当多个 goroutine 访问共享的变量 v 时,必须使用同步事件来建立 happens before 条件确保读取到期望的写入。
变量 v 初始化为零值在内存模型中的行为就是一次写。
对于读写超过单个机器字(machine word)的数值,其实表现为多次机器字大小的操作,顺序是不定的。
同步
程序一开始在单个 goroutine 中运行,但随着进程会创建其他 goroutine,并发运行。
goroutine 创建
go
语句会在 goroutine 执行开始之前启动一个新的 goroutine
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
调用 hello
会在未来某个时间点打印出 "hello, world"
(可能在 hello
已经返回之后)。
goroutine 销毁
goroutine 的退出不能保证在程序中的任意事件之前发生。
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}
对于 a
的赋值后面没有任何同步事件,所以不能保证任意其他 goroutine 能观察到。实际上,一个侵入式编译器甚至会删掉整个 go 语句。
如果一个 goroutine 的结果必须要被另一个 goroutine 观察到,就要使用锁或者通道通信这类同步机制来建立一个相对顺序。
通道通信
通道通信是同步 goroutine 的主流方法。 向特定的通道发送都对应了在另一个 goroutine 中从该通道接收。
向通道发送发生在从该通道接收完成之前。
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
接收完成之前,而在 print
之前发生。
通道的关闭在接收到零值之前。
在上面的例子中,将 c <- 0
替换成 close(c)
效果相同。
从无缓冲通道接收总是在向通道发送完成之前发生。
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
发送之前发生,都在发生在 print
之前。
如果通道有缓冲(比如 c = make(chan int, 1)
),程序就没法保证打印出 "hello, world"
了。
从容量为 C 的通道第 k 次接收总是在第 k + C 次发送完全之前发生。
这条规则允许通过缓冲通道来实现一个计数信号量(semaphore):
- 通道中的元素数量对应活跃使用的数量
- 通道的容量对应了同时使用的最大数量
- 发送一个元素要先获取信号量,接收一个元素后释放信号量
这就是限制并发的常用手段。
以下程序为工作列表中的每一项都开启一个 goroutine,但使用 limit
来协调 goroutine 确保同时至多只有三个在执行任务。
var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func(w func()) {
limit <- 1
w()
<-limit
}(w)
}
select{}
}
锁
sync
包提供了两种锁实现:
sync.Mutex
sync.RWMutex
var l sync.Mutex
var a string
func f() {
a = "hello, world"
l.Unlock()
}
func main() {
l.Lock()
go f()
l.Lock()
print(a)
}
首次调用 l.Unlock()
在第二次 l.Lock()
调用返回前发生,都在 print
之前发生。
Once
sync
还提供了 Once
类型用于多 goroutine 安全初始化:只有一个 goroutine 能够执行 f()
,其他调用者都会阻塞至 f()
返回。
once.Do(f)
的某次 f()
调用在 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()
}
twoprint
只会调用 setup
函数一次。setup
函数在调用 print
之前完成。结果就是 "hello, world"
将被打印两次。
错误的同步
读事件 r 可能观察到并发的写事件 w 写入的值。即使某一次发生了,也不代表在 r 之后发生的读能观察到 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 的。
双重锁避免同步开销。举个栗子,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
的写入,这段代码同样可能输出空字符串。
甚至,都无法保证对 done
的写入能被 main
观察到,因为两个 goroutine 之间没有同步事件。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
的值。
以上所有案例,解决方案都一样:使用显式的同步。