Go memory model

211 阅读5分钟

本文已参与掘金创作者训练营第三期「话题写作」赛道,详情查看:掘力计划|创作者训练营第三期正在进行,「写」出个人影响力

这个命题,其实是两个主题:Gomemory model

  1. memory model 针对什么?
  2. Go 有什么关系

为什么要提出这两个问题?

首先你要知道 memory model 针对什么问题,每个语言都可能会针对这个问题给出自己的做法和规范。

其次,Go 提出了什么规范和建议?

带着这两个问题,才能比较好的理解 Go memory model 这个主题。

为什么需要 memory model

问题的背景是多线程并发。在不同的编程语言,不同的编译器 (重排序),不同的处理器 (CPU缓存),在程序并发运行下,会产生和预期不一样的结果。

而我们的目的是:程序结果正确 → 这个就是 memory model 需要面临的问题,也是需要解决的问题。

指令重排

一句话:代码不会按照你写的顺序来执行

type T struct {
	  value int
}

var done *T
func factory() {
		t := new(T)
		t.value = 13
		done = t
}

func main() {
		go factory()
		for done == nil {}
		print(done.value)
}

可能出现的情况:

  1. main goroutine 没有检测到 factory goroutine 对 done 的写操作,g 一直为 nil
  2. main goroutine 观察到 g 不为 nil,也可能打印出空的 msg ⇒ g 的赋值没有完成,但是至少 g 不是 nil

首先这个是可能的情况,不一定会发生,因为程序运行中各种操作的顺序可能不会得到保证,所以在各种乱序下,程序的结果就迷惑起来了。

以上说的是代码重排序,还有一种:指令并行重排序。

现代的 CPU 都是流水线的,同时在执行的指令有多条 (在执行的不同阶段,这与 CPU 架构有),如果 CPU 认为,你代码中的这些指令不存在相关性,那么它就会选择并行执行。并行执行后,代码的顺序进一步被打乱。

这个时候就有一个概念:happens-before

happens-before,在很多语言都有这个概念

happens-before,描述的是两个时间发生的顺序关系。如果在程序运行过程中,指定操作之间的 happens-before 关系,这样就可以保证程序发生的顺序。

CPU缓存

为了提升程序运行效率,CPU 在从内存取数据之后,会把数据存在缓存中,下次取数据的时候,直接从缓存中拿。

当然,缓存数据最终会写入内存,但是在程序运行的过程中,可能内存中的数据不是最新的,最新数据在缓存中。

在现代 CPU 中,一般有多个核,每个 CPU 核都会有自己的缓存,但是内存只有一块,是共享的。何时从缓存中读取数据,CPU 的优化有个标准,就是“线程级”结果正确,也就是说,当 CPU 认为,对于当前线程(不考虑其他线程的修改),如果内存和缓存中的数据一致,那么会用缓存中的数据。

当然,如果有其他线程对共享的内存做了修改,就会导致 CPU 没有意识到内存已经变化,而仍然取缓存中的数据,导致执行错误。

总结

总结一下目的:

  1. 在编译器(指令重排)和硬件(不同的CPU架构)的优化下,语言需要一个规范来明确并发环境下一个变量的可见性和顺序
  2. 给开发人员一个并发保障,并提供一些访问控制 (一些组件和库) → 串行化访问

最终一句 🌹 来说明:如何在多线程并发的情况下,限制编译器优化、指令并行优化,控制线程间的变量传递,使得程序计算结果正确

Go 设计提倡

Don't communicate by sharing memory, share memory by communicating. — go-proverbs.github.io/

这句话如果不知道的,先去把 Go 去看看 (默认大家知道)。

但是这句话不是在 Go 中先提出的,而是 Erlang 这个为通信而生的语言 (rabbitmq 由此语言开发)。阐述了 Go 并发模型:『使用通信来共享内存』。

这个 “通信” 意味什么?

  1. 通过通信同步不同组件之间的消息 → 解决行为
  2. 通过通信来共享内存就不会导致线程间的竞争 → 解决竞争

竞争

竞争的来源:并发访问共享内存。按照上面说的 Go 提倡:

将共享变量 → channel ,由于同一时间只能有一个 goroutine 访问 channel (内部也是有一个 mutex) ;对于 channel 而言,producer → channel,对于变量的 ownership (借用一下 rust 的概念) 就转交给 channel,而不同的接收方此时也只有一个可以访问 channel,channel → consumer,接受者获取到该 msg 后就可以根据该消息做一系列操作。

Go 是怎么规定的

说一下整体规则:

  1. 使用channel操作或其他同步原语(sync/atomic包中的原语)来实现串行化访问
  2. 多个 goroutines 同时访问的数据,必须串形化访问

既然提到 goroutine,先就来说说这个

goroutines

go func() 执行,一定 happens-before 此 goroutine 内的代码执行。

var a string

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

func main() {
	hello()
}

说明一下整个 happens-before 的执行情况:

  1. a = "hello, world"
  2. go f()
  3. f() → print(a)

所以该程序是可以正常输出:hello, world

channel

channel 充当通信的重要角色,在前面的论述中已经提到了。这里说说 channel 的 happens-before 的原则:

  1. send channel happens before recv channel
  2. close channel happens before read channel
  3. unbuffered channel,recv channel happens before send channel
  4. buffered channe,第 n 个 receive 一定 happens before 第 n+m 个 send 的完成

对于 3 给个例子:

var ch = make(chan int)
var s string

func f() {
  s = "send from f()"
  <-ch
}

func main() {
  go f()
  ch <- struct{}{}
  print(s)
}

你可以认为,在 ch <- struct{}{} 执行时,s 已经被赋值好,所以后面的 s 是可以输出的。Go memory model