Go内存模型

这是对官方的The Go Memory Model文档的翻译兼笔记。

介绍

Go内存模型明确了在何种情况下在一个goroutine中读取一个变量时能够保证他能观察(observe)到被另一个goroutine写入到该变量的值。

建议/忠告

程序在修改可能被多个goroutine访问的数据时,应该把这种访问序列化。

为了序列化访问,可以使用 channel操作保护数据,或者一些syncsync/atomic包中的同步原语。

如果你的程序的行为需要阅读本文档接下来的内容才能理解,那你太聪明了。

但是,不要太聪明。

事件时序

在单个goroutine中,读写操作的行为一定以程序指定的顺序执行。在单个goroutine中,编译器和处理器只有在不会改编语言规范定义行为的情况下才可能对指令重排序。因为这种重排序,一个goroutine中观察到的执行顺序和另一个goroutine可能不太一样。例如,在一个goroutine中执行了 a=1; b=2;,另一个goroutine可能看到b在a之前更新了值。

为了明确读、写的必要体检,我们定义 happens before(事件发生顺序),它表示在go程序中执行内存操作的偏序关系。如果事件 e1在事件 happens before e2,我们说 e2 happens after e2。同理,如果 e1 没有 heppen before e2,同时也没有 happen after e2,那么我们说 e1 和 e2是并发发生的(happen concurrently)。

在单个gorouine中, happens-before 顺序即程序定义的顺序。

如果下面两个条件符合,那么读取变量v的操作(r)允许监控对v的写操作(w):

  1. r 不在 w 之前发生
  2. 在w之后和r之前没有其他的写操作发生

为了保证读取变量v的操作r监控一个指定的针对v的写操作w,保证w是r允许监控的唯一的写。也就是,如果下面两个条件符合,r会保证监控到w:

  1. w happens before r
  2. 其他对共享变量的写操作的 w 之前或者在 r 之后

这里条件比开始的两个条件更加严格,他需要保证在 w 还 r 之间没有其他的写操作并发发生。

在单个goroutine中,没有并发执行,所以这两个定义是相等的。读操作r监控最近的对变量v的写操作写入的值。当多个goroutine访问一个共享的变量时,他们必须使用同步事件建立 happens-before 关系,以确保读操作监控到预期的写入。

在go的内存模型中,对v类型变量v的0值初始化表现对变量的写入。

对大于单个机器字的值的读写操作,表现如同以不确定的顺序对多个机器字大小的值的操作。

同步/Synchronization

初始化

程序的初始化运行在一个单独的goroutine中,但这个goroutine可能创建多个并行运行的goroutine。

如果package a导入了package q,那么q的初始化操作在任何p的操作之前完成。

main.main函数在所有init函数结束之后开始。

创建 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监控到,使用诸如lock或channel通信之类的同步机制来建立一个相对顺序。

channel通信

channel通信是goroutine间同步的主要方法,每个向指定的channel发送都有一个与之匹配的从该channel接受的操作,通常这两个操作在不同的goroutine中。

向channel发送的操作在对应的接收操作完成之前发生(以此获得一个偏序关系)。

下面的程序:

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”。写入操作在发送到管道c之前,而该发送操作在对应的接收操作完成之前,而print在接受操作之后。

信道在接受操作之前关闭信道,那么接受操作返回一个零值,因为信道被关闭了。所以在上面的代码中, c <- 0 可以用 close(c) 替代也可以实现相同的效果。

一个无缓冲channel的接收操作在对应的send操作完成之前发生(这对于理解无缓冲信道很重要)。所以下面的代码也能保证打印出 “hello, world”(和上面的代码相似,但是使用一个无缓冲的channel,并且调换了send和receive的语句)

var c = make(chan int)
var a string

func f() {
a = "hello, world"
<-c
}
func main() {
go f()
c <- 0
print(a)
}

程序首先对a进行写入,然后从c中接受信号,随后向c发送对应的信号,然后执行print。

若上面的代码使用带缓冲的信道则不能保证打印出 “hello, world”(即使是 c=make(chan int, 1))。

对容量为C的信道,第K个接收操作在第 k+C个发送操作完成之前发生。

这一条规则总结了上面对有缓冲信道的规律,它可以使用一个缓冲信道实现一个计数信号量:信道中元素的数量对应活跃的使用数,信道的容量对应计数器的最大值,发送一个元素对应计数的增加,从信道中接收一个元素,对应计数减一。这就是常说的并发控制。

下面的代码为woke列表的每一项启动一个goroutine,但使用 limit 这个信道来协调这些goroutine,保证任何时候最多只有三个运行的work的函数:

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.Mutexsync.RWMutex

对于任何 sync.Mutex 或 sync.RWMutex 类型的变量 l ,对任何 n < m 的整数,对 l.Unlock() 的第 n 次调用在对 l.Lock() 的第 m 次调用返回前发生。 (也就是 Unlock 的调用次数要在更多次的Lock调用返回之前发生),如n=1,第一此unlock调用需要在第2次的Lock操作返回之前发生(互斥量语义)。

下面的代码:

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()发生在第二次调用 l.Lock()返回之前,随后才调用print。

对于 RWLock的规则比较复杂:

For any call to l.RLock on a sync.RWMutex variable l, there is an n such that the l.RLock happens (returns) after call n to l.Unlock and the matching l.RUnlock happens before call n+1 to l.Lock.

一般场景不要使用 RWMutex, 使用Mutex就够了,如果需要使用再仔细研究。

Once

sync 包通过Once这种类型提供在多个goroutine中初始化的安全机制。多个线程可以对指定的 f 执行 once.Do(f) 但是只有一个会执行 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 会打印两次 “hello, world”,第一次调用doprint会执行一次 setup 。可以理解, Once.Do()的参数是一个不带参数的函数(指针)。

错误的同步示例

注意,读操作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()
}

上面的代码可能打印出 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的写入。上面的代码可能错误地打印了eyige空字符串而不是 “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函数监控到,因为两个线程之间没有同步事件。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的初始化值。

对于上面这些错误的使用场景,解决方案都是一样的: 使用显式的同步机制。

原文:

  1. https://golang.google.cn/ref/mem
  2. https://go-zh.org/ref/mem