go 内存模型

简介

go的内存模型旨在说明:一个协程中对变量v的写入产生的值可以保证被另一个协程中的对变量v的读取观察到。

Happens Before

在一个协程内,读写操作必须按照程序指定的顺序进行。在一个协程内,编译器和处理器可能对读写操作重写排序,但是这个排序的前提是:在当前协程内,不会改变程序的执行行为。但是这个重新排序是不保证其他协程观测到执行顺序是不改变的。比如在协程1中a=1;b=2,但在其他协程的感知中,可能b比a先更新值。

我们这里定义Happens Before(在...之前发生),如果事件e1在事件e2之前发生,那么我们就可以说e2在e1之后发生。如果e1既不在e2之前发生,也不在e2之后发生。那么e1和e2就是同时发生的(并发)。

在一个协程内,Happens Before的顺序就是程序表达的那样。

如果下面两点可以保证,就说明对变量v的读取r允许观察到对变量v的写入w:

  • r不是在w之前发生
  • 在w之后并且r之前没有其他的对v的写入w’

为了保证变量v的读取r观察到v的特定写入w,并且保证w是唯一允许被r观察到的。也就是说,r保证能观察到w。需要做到下面两点:

  • w在r之前发生
  • 其他的对v的写入w’要么发生在w之前,要么发生在w之后

下面两点比上面两点要求更为严格。它保证了没有其他的写入w’和w、r同时发生。

在一个协程内,因为没有并发,所以这两种定义是一致的:读取r可以观察到写入w对变量v最近一次的写入。但是当多个协程同时访问同一个共享变量时,就必须使用同步事件来建立Happens Before语义来保证读取r可以观察到指定的写入w。

在内存模型中,对变量v以0值初始化是一次写入。

对于大于单机器字节的读取和写入,可以看做是对多个单机器字节的乱序操作。

同步

初始化

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

如果包p引入了包q,则q的初始化函数会在p的初始化函数之前运行。

main.main函数在所有的初始化函数之后运行。

协程创建

go关键字创建协程发生在协程运行之前。

协程销毁

协程的销毁不保证在程序中的任何事件发生之前。比如:

var a string

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

这个赋值没有跟随任何同步事件,所以它不保证被其他协程观察到。事实上,激进的编译器会删除整个go语句。

如果需要,可以使用同步原语比如管道通信来建立一个相关的执行顺序。

管道通信

在go的协程中,管道通信是非常重要的一个同步方法。通常发送方和接受方在两个不同的协程中,利用发送和接收这两个有序的动作来进行同步。

1.在有缓冲的管道中,发送一定发生在接收完成前。(A send on a channel happens before the corresponding receive from that channel completes.

例如:

var c = make(chan int, 10)
var a string

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

func main() {
    go f()
    <-c
    print(a)
}

a = "hello, world"一定在c<-0之前发生,c<-0一定在<-c之前发生,<-c一定在print(a)之前发生。这样就能保证a = "hello, world"print(a)之前发生。则保证可以打印出hello, world

2.管道的关闭一定发生在从管道中接收值之前。

因此上面的例子将<-c替换成close(c)也是可以的。

3.在无缓冲管道中,接收一定发生在发送完成前。(The closing of a channel happens before a receive that returns a zero value because the channel is closed.)

例如下面的例子将发送和接收语句互换了位置。

var c = make(chan int)
var a string

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

如果上述例子中管道是有缓冲的(e.g., c = make(chan int, 1)) ,就无法保证一定能打印出hello,world

4.在容量为C的管道中,第k个接收发生在k+C个发送完成之前。(The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes.)

第4点推广了第一点的规则。这里其实有一点绕,举个例子:第1个接收发生在1+C个发送完成之前。首先思考:第一个接收能否保证在0+C个发送完成之前?答案是不能。因为管道有C个容量的缓冲,C个发送语句发送完成前,完全可以不调用接收语句。那第1个接收发生在1+C个发送完成之前如何保证,我们知道,当管道缓冲满了之后,就无法向管道中发送,发送语句会阻塞。因此必须在接收之后发送语句才能继续执行。

第四点规则使得计数信号量可以由缓冲管道建模:管道中的数量对应了当前并发量,管道的容量对应了最大并发量。发送语句占用一个信用量,接收语句释放一个信号量。这是限制并发量的一个惯用手段。

下面的例子限制了最大并发量为3:

var limit = make(chan int, 3)

func main() {
    for _, w := range work {
        go func(w func()) {
            limit <- 1
            w()
            <-limit
        }(w)
    }
    select{}
}

同样的,我们也可以用lock和once来实现happens before语义

不正确的同步方式

即使读r可以观察到同时发生的写w的值,但这并不意味这在r之后发生的读r’可以观察到在w之前发生的写w’。例如:

var a, b int

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

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

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

这段程序可能打印出2、0

这种现象使得一些常见的方式失效。比如双重锁定检查

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()
}

这段程序不能保证print(a)时能够观察到a的值一定是hello, world

同样的,还有一种循环等待的写法也可能有问题,例如:

var a string
var done bool

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

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

这段程序也不保证print(a)一定能打印出内容,甚至更坏的情况下无法观察到done发生
了改变,因此程序会死循环下去。

还有一种衍生版本的写法也会有问题

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被赋值,也不一定能观察到g.msg有值。

参考

The Go Memory Model(原文)