菜单

内存模型

相关源文件

Go 内存模型定义了一个 goroutine 中的变量读操作可以观察到另一个 goroutine 中对同一变量写操作产生的值的条件。本文档解释了 Go 内存模型的正式定义、同步机制以及编写并发 Go 程序的实践指南。

介绍

Go 通过 goroutines 和 channels 提供了强大的并发特性,但随之而来的是理解在并发上下文中内存操作如何行为的需求。Go 内存模型保证了在一个 goroutine 中所做的更改在另一个 goroutine 中何时可见。

Go 内存模型文档中最重要的一条建议很简单:

修改被多个 goroutine 同时访问的数据的程序必须串行化此类访问。

为了串行化访问,请使用 channel 操作或其他同步原语(如 `

sync` 和 `

sync/atomic` 包中的原语)来保护数据。

在没有数据竞争的情况下,Go 程序行为就如同所有 goroutine 都被多路复用到单个处理器上一样。这个属性被称为“DRF-SC”(无数据竞争的顺序一致性)。

来源:doc/go_mem.html13-42 doc/go_mem.html44-63

内存模型的正式定义

Go 的内存模型通过几个关键概念来定义:

内存操作

内存操作由四个方面表征:

  • 它的类型(读、写或同步操作)
  • 它在程序中的位置
  • 正在访问的内存位置或变量
  • 读取或写入的值

有些操作是读操作(读取数据、互斥锁、channel 接收),而另一些是写操作(写入数据、解锁互斥锁、发送 channel 消息、关闭 channel)。

操作之间的关系

正式定义建立了几个关系:

  1. 顺序发生 (Sequenced before):单个 goroutine 内的操作顺序,由 Go 的控制流定义。

  2. 同步发生 (Synchronized before):同步操作上的部分排序。如果同步读操作观察到了同步写操作,则写操作发生在读操作之前。

  3. 发生 (Happens before):顺序发生和同步发生关系的联合传递闭包。

数据竞争

当来自不同 goroutine 的两个操作访问同一内存位置,其中至少一个是写操作,并且它们没有被“发生”关系排序时,就会发生数据竞争。

数据竞争可以分为:

  • 读写数据竞争:对同一位置的读和写,没有发生关系排序。
  • 写写数据竞争:对同一位置的两个写,没有发生关系排序。

如果程序没有数据竞争,那么它的行为将是顺序一致的,就如同来自不同 goroutine 的操作以某种一致的总顺序交错执行一样。

来源:doc/go_mem.html79-199

实现限制

Go 的内存模型对实现遇到数据竞争程序时可以做什么施加了限制:

  1. 任何实现都可以检测到数据竞争并停止程序执行。运行带有 `-

    race` 标志的程序时就会发生这种情况。

  2. 对于单字或子字内存位置,读操作必须观察到来自先前发生或并发发生的写操作的值。

  3. 对于多字读/写,不能保证观察到来自单个写操作的一致值。

这些限制可以防止数据竞争的一些最坏后果,使 Go 程序比 C 或 C++ 等语言更具可预测性,在这些语言中,数据竞争会导致未定义行为。

来源:doc/go_mem.html216-267

同步机制

Go 提供了几种建立不同 goroutine 之间操作的发生关系(happens-before relationships)的机制。

初始化

包初始化按定义好的顺序执行

  • 如果包 `

    p` 导入了包 `

    q`,则 `

    q` 的 `

    init` 函数在 `

    p` 的任何函数开始之前完成。

  • 所有 `

    init` 函数都在 `

    main.main` 开始之前完成。

Goroutine 创建

启动新 goroutine 的 `

go` 语句在 goroutine 执行开始之前是同步的。这确保了在 `

go` 语句之前初始化的变量对新 goroutine 是可见的。

Channel 通信

Channels 是 goroutine 之间进行同步的主要方式。

  1. 对 channel 的发送操作发生在相应接收操作完成之前同步。
  2. 关闭 channel 操作发生在返回零值(因为 channel 已关闭)的接收操作之前同步。
  3. 对无缓冲 channel 的接收操作发生在相应发送操作完成之前同步。
  4. 对于有缓冲 channel,第 k 次接收发生在第 k+C 次发送完成之前同步,其中 C 是 channel 的容量。

`

sync` 包中的 Mutex 和 RWMutex 提供了同步机制。

  • 对于互斥锁 `

    l`,第 n 次调用 `

    l.Unlock()` 在第 m 次调用 `

    l.Lock()` 返回之前是同步的,其中 n < m。

  • 对于 RWMutex,对于各种锁定/解锁方法也有类似的保证。

Once

`

sync.Once` 类型确保函数即使在多个 goroutine 的存在下也只执行一次。

  • 一次 `

    once.Do(f)` 的完成与任何调用 `

    once.Do(f)` 的返回是同步的。

原子值

`

sync/atomic` 包中的操作建立发生关系(happens-before relationships)。

  • 如果原子操作 A 被另一个原子操作 B 观察到,则 A 在 B 之前同步。
  • 所有原子操作似乎都以顺序一致的顺序执行。

来源:doc/go_mem.html284-637

常见的同步错误

错误的双重检查锁定(Double-Checked Locking)

此代码不正确,因为将 `

done` 视为 true 并不能保证对 `

a` 的写入是可见的。

错误的忙等待(Busy Waiting)

此代码不正确,原因与前一个示例相同。此外,编译器可能会将忙等待循环优化成一个无限循环。

这两种情况的正确方法是使用适当的同步原语。

来源:doc/go_mem.html640-783

编译器限制

Go 的内存模型也限制了哪些编译器优化是有效的。编译器不得

  1. 引入原始程序中不存在的写入操作
  2. 允许单个读取操作观察多个值
  3. 允许单个写入操作写入多个值

这些限制确保了带有数据竞争的 Go 程序的行为,虽然不是完全定义的,但比 C/C++ 等语言更具可预测性。

来源:doc/go_mem.html787-963

实践指南

以下是一些编写正确并发 Go 程序的实践指南:

  1. 优先使用 channels 在 goroutines 之间进行通信。
  2. 不要通过共享内存来通信;通过通信来共享内存。
  3. 当需要直接共享时,请使用 `

    sync` 和 `

    sync/atomic` 包中的同步原语。

  4. 请注意,即使是备受推崇的并发模式,如双重检查锁定,如果没有适当的同步也是无效的。
  5. 使用竞速检测器(`

    go build -race`、`

    go test -race`)来识别数据竞争。

如有疑问,请遵循 Go 内存模型文档的建议:

如果你必须阅读本文档的其余部分来理解程序的行为,说明你太聪明了。

不要耍小聪明。

来源:doc/go_mem.html25-42

结论

Go 的内存模型提供了一套清晰的规则,用于理解一个 goroutine 中的内存操作何时对另一个 goroutine 可见。通过遵循正确的同步实践并避免数据竞争,您可以编写出正确且高效的并发程序。

在没有数据竞争的情况下,Go 提供顺序一致性,使程序更容易推理。当存在数据竞争时,Go 提供的保证比 C/C++ 多,但比完全顺序一致的模型少。

请记住,竞速检测器是识别程序中数据竞争的宝贵工具,但它无法检测到所有竞速。最好的方法是从一开始就通过使用适当的同步机制来设计程序,使其避免数据竞争。