Go 内存模型

Mar 26, 2022 17:00 · 2183 words · 5 minute read Golang

Go 的内存模型确保一个 goroutine 中读取一个变量时,能观察到不同 goroutine 写入同一变量的值。

建议

多个 groutine 同时修改数据最好串行化。

串行化访问,通过 channel 操作或其他同步原语来保护数据,比如 syncsync/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

  1. r 不在 w 之前发生。
  2. r 之前 w 之后没有其他对 v 的写入。

为了保证 r 读取变量 v 观察到特定的 w 写 v,确保 wr 被允许观察到的唯一写入。也就是说如果下面两个条件成立 r 就能观察到 w

  1. wr 之前发生。
  2. 任意对共享变量 v 的写入要么在 w 之前要么在 r 之后。

这对条件比第一对更强;它要求没有其他写入与 wr 并发发生。

在单个 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 包提供了两种锁实现:

  1. sync.Mutex
  2. 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 的值。

以上所有案例,解决方案都一样:使用显式的同步