[翻译] [The Go Memory Model](https://go.dev/ref/mem)


[翻译] The Go Memory Model

目录
  • [翻译] The Go Memory Model
    • Introduction (简介)
    • Advice (建议)
    • Happens Before
    • Synchronization (同步)
      • Initialization (初始化)
      • Goroutine creation (协程的诞生)
      • Goroutine destruction (协程的销毁)
      • Channel communication (Channel 通信)
        • channel 的发送操作发生在对应的接收操作完成之前 (happens before)
        • 对 Channel 的 close 操作发生在 Channel 的接收操作之前 (happens before),且由于 Channel 被关闭,接收方将会收到一个零值
        • unbuffered channel 的接收操作发生在发送操作完成之前 (happens before)
        • 一个容量为 c 的管道上的地 k 个接收操作发生在第 (k + c) 个发送操作之前 (happens before)
      • Locks (锁)
        • 对于类型为 sync.Mutex 或 sync.RWMutex 的变量 l,在 n < m 的情况下,对 l.Unlock() 的第 n 次调用发生在 l.Lock() 的第 m 次调用的返回之前 (happens before)
        • 类型为 sync.RWMutex 的变量 l,对任何一次 l.RLock() 的调用,都会存在一个 n,使得 l.RLock() 发生在第 n 次调用 l.Unlock() 之后,并发生在第 n + 1 次 l.Lock 之前
      • Once
    • Incorrect synchronization (错误的同步)

Introduction (简介)

Go 内存模型指定了在什么情况下,一个协程对变量的写操作可以被另一个协程读到。

Advice (建议)

当一份数据同时被多个协程访问,在对这份数据进行修改时,需要保证对这份数据的访问时按照一定顺序进行的。

为了让访问有序,需要使用 channel 或者其他同步原语, 在 syncsync/atomic 下面就提供了很多同步原语。

如果你一定要读剩下的内容以便理解你写的程序的行为,那你真是太聪明了。

可太聪明也不是一件好事。

Happens Before

在一个协程中,读写必须按照程序指定的顺序执行。

也就是说,在一个协程中,虽然编译器和处理器可能会对读写顺序重新排序,但是重排序的结果必须不能破坏上面的规定。

因为存在这种重排序机制,一个协程观测到的执行顺序可能和另一个协程不同。例如,如果一个协程执行 a = 1; b = 2; ,另一个协程看到的顺序可能是:先更新 b 为 2,再更新 a 为 1。

我们定义了 happens before 来指定读和写的顺序。

  • 如果事件 e1 发生在 e2 之前 (happens before),则描述为 e2 发生在 e1之后 (happens after)
  • 如果 e1 既不在 e2 之前发生,也不知 e2 之后发生,则描述为 e1 和 e2 并发发生 (happen concurrenctly)

在一个协程中,happens-before 的顺序就是程序的代码的顺序。

当满足如下条件时,对变量 v 的读操作 r 被允许 (is allowed) 观测到对 r 的写操作 w

被允许观测到并不意味者一定可以观察到?

  1. r 不发生在 w 之前 (not happen before)
  2. 没有其他对 v 的写操作 w',其中 w' 发生在 w 之后 (happens after) 且发生在 r 之前 (happes before)

为了保证 (guarantee) 读操作 r 可以观测到写操作 w 的结果,需要确保 w 是唯一的写操作。也就是说,当满足下面的要求时,r 保证可以观测到 w

  1. w 发生在 r 之前 (happens before)
  2. 其他对共享变量 v 的写操作要么发生在 w 之前 (happens before) ,要么发生在 r 之后 (happens after)

这一对条件比上一对条件更严格,它要求没有其他写操作与 wr 同时发生。

在同一个协程内,由于没有并发,所以两条定义是等价的:最近的一条对变量 v 的写操作 w 会被读操作 r 观测到。当有多个协程访问共享变量 v 时,必须使用同步原语来建立 happens-before 条件以保证读操作可以观察到期待的写操作结果。

在内存模型中,初始化一个类型为 t ,值为 0 的变量 v 时,视为一次写操作。

当读写超过一个 machine word (机器字) 大小的变量时,将会产生多个机器字大小 (totalSize / singleMachineWordSize) 的读写操作,这些操作的顺序是未指定的。

Synchronization (同步)

Initialization (初始化)

程序的初始化操作在一个主协程中执行,这个主协程会创建其他的协程,这些协程并发执行。

如果包 p 导入了另一个包 qq 里面的 init 方法们将会在 pinit 方法之前被执行 (happens before)。

main 方法将会在所有的 init 方法执行完之后再执行 (happens after)。

Goroutine creation (协程的诞生)

go 关键字将会开启一个新协程,发生在协程开始执行之前 (happens before) (即在创建协程之后,协程才开始执行)

例如,在这个程序中

var a string

func f() {
	print(a)
}

func hello() {
	a = "hello, world"
	go f()
}

调用 hello 将会在某一时刻打印 "hello, world" (有可能在 hello return 后才打印)

Goroutine destruction (协程的销毁)

Go 内存模型没有保证协程的退出时刻会发生在程序中的某个事件之前 (happens before),例如,在下面的程序中

var a string

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

在为 a 赋值后,后面并没有接任何同步原语,所以并不能保证其他协程一定可以看到 a 更新之后的值。事实上,有些激进的编译器甚至可能会直接将 go func() 那一行给优化掉 (delete) 。

如果需要一个协程的结果被其他协程看到,则必须使用同步机制 (例如锁或者 channel 等) 来为这些事件建立一个相对的顺序。

Channel communication (Channel 通信)

Channel 通信是在多个协程间进行同步的最主要方法。同一个 Channel 上的发送和接收是一一对应的,通常发送和接收操作是在不同的协程上进行的。

channel 的发送操作发生在对应的接收操作完成之前 (happens before)
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 的发送操作之前 (happens before)
  • c 的发送操作发生在 c 的接收操作完成之前 (happens before)
  • c 的接收操作发生在 print 之前 (happens before)
对 Channel 的 close 操作发生在 Channel 的接收操作之前 (happens before),且由于 Channel 被关闭,接收方将会收到一个零值

在之前的例子中,如果使用 close(c) 来替换 c <- 0 , 读写行为不会发生改变

unbuffered channel 的接收操作发生在发送操作完成之前 (happens before)

下面的程序和之前的差不多,只不过交换了发送和接收语句的位置并使用了一个 unbuffered channel

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 的接收操作之前 (happens before)
  • c 的接收操作发生在 c 的发送操作完成之前
  • c 的发送操作发生在 print 操作之前

如果 channel 是一个 buffered channel , (例如 c = make(chan int,1)) , 那就无法保证打印出 "hello, world" 了。(它最终将会输出一个空字符串,crash 或其他未知的事情)

一个容量为 c 的管道上的地 k 个接收操作发生在第 (k + c) 个发送操作之前 (happens before)

这条规则可以视为对上面规则的拓展,(当 c = 0 时就是一个 unbuffered channel 了),可以使用 buffered channel 封装出一个信号量 (semaphore),用 channel 里面的元素数量来代表当前正在使用的资源数量,channel 的容量表示同时可以使用的最大资源数量。当申请信号量时,就往 channel 中发送一个元素,释放信号量时就从 channel 中接收一个元素。

下面的程序为 work 列表中的每个元素都开启了一个协程,并使用名字 limit 的 channel 来协调协程,让同一时刻最多有三个方法在执行

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.Mutexsync.RWMutex 的变量 l,在 n < m 的情况下,对 l.Unlock() 的第 n 次调用发生在 l.Lock() 的第 m 次调用的返回之前 (happens before)
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() 的第一次调用 (在 f() 内) 发生在第二次调用 l.lock() 返回之前 (在 main) (happens before)
  • 第二次调用 l.lock() 发生在 print(a) 之前 (happens before)
类型为 sync.RWMutex 的变量 l,对任何一次 l.RLock() 的调用,都会存在一个 n,使得 l.RLock() 发生在第 n 次调用 l.Unlock() 之后,并发生在第 n + 1 次 l.Lock 之前

ps: 换句话说就是一旦拿了写锁,除非写锁释放,否则无法拿到读锁;一旦拿到读锁,除非读锁释放,否则无法拿到读锁。

Once

sync 包内 Once 类型为在多协程场景下的初始化提供了一个安全的机制,当多个线程执行 once.Do(f) 时,只有一个能成功执行 f(),其他线程对 once.Do(f) 的调用会被阻塞住,直到 f() 返回

once.Do(f) 中 f() 将会在所有的 once.Do(f) 返回之前返回 (happens before)

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 方法仅仅会调用一次 setupsetup 将会在 print 之前完成 (happens before)。结果将会是打印两次 "hello, world"

Incorrect synchronization (错误的同步)

注意读操作 r 可能会观察到与它并发执行的写操作 w (happens concurrently),即使这种情况发生了,也并不能表示发生在 r 之后 (happens after) 的其他读操作可以观察到发生在 w 之前 (happens before) 的其他写操作。

var a, b int

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

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

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

g() 可能会发生先输出 2 再输出 0 的情况。

这个事实意味着一些常用的技巧可能会失效。例如双重检查锁 (Double-checked locking) 以及忙等待 (busy waiting)。

双重检查锁可以避免同步时的额外开销,例如,下面的 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 变量被更新为 true,也并不能保证 a 变量被更新为 "hello, world" 了。因此上面的程序可能会打印出一个空字符串。

下面是一段忙等待的代码,它的原本目的是:一直等下去,直接 a 被赋值。

var a string
var done bool

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

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

和上面一样,观察到 done 的写操作并不能表示能观察到对 a 的写操作。所以这段代码也可能会打印出一个空白的字符串。更糟的是,由于不能保证 done 的写操作一定会被 main 观察到,main 里面的 loop 可能永远都不会退出。

还有一个类似的例子,看下面这段代码

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 被初始化之后的结果

上面的这些例子的解决方案都是一样的,那就是显示地使用同步操作 (use explicit synchronization)。