内存模型和指令重排

250 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第1天,点击查看活动详情

go的内存模型:并发环境中多 goroutine 读相同变量的时候,变量的可见性条件。

  • 什么条件下goroutine 在读取一个变量的值的时候,能够看到其它 goroutine 对这个变量进行的写的结果。

为什么会出现这个问题:

  • 因为会发生指令重排和多级Cache的存在,使得多线程同时访问同一个变量的可见性和顺序需要一个规范。这个规范叫做内存模型。

为什么定义内存模型:

  • 想程序员提供一种保证,在面对同一个数据同时被多个goroutine访问的情况,可以做一些串行化访问的控制,比如使用channel或者sync包和sync/atomic包中的并发原语
  • 允许编译器和硬件对程序做一些优化

指令重排和可见性问题

程序在运行的时候,两个操作的顺序可能不会得到保证。重排以及多核 CPU 并发执行导致程序的运行和代码的书写顺序不一样。但是,如果某些操作能够提供happens-before关系,我们就可以100%保证指令运行的顺序。

在一个goroutine 内部,程序的执行顺序和它们的代码指定的顺序是一样的。对于另一个 goroutine 来说,重排却会产生非常大的影响。因为 Go 只保证 goroutine 内部重排对读写的顺序没有影响。即一个线程内部的执行顺序对另一个线程来说是不确定的。

Go内存模型通过happens-before定义两个事件的顺序,要么先发生要么后发生,否则认为是同时。

tips:

  1. 在 Go 语言中,对变量进行零值的初始化就是一个写操作。
  2. 如果对超过机器 word(64bit、32bit 或者其它)大小的值进行读写,那么,就可以看作是对拆成 word 大小的几个读写无序进行。
  3. Go 并不提供直接的 CPU 屏障(CPU fence)来提示编译器或者 CPU 保证顺序性,而是使用不同架构的内存屏障指令来实现统一的并发原语。

Go提供的happens-before保证

  • init函数的初始化是在单一的goroutine执行的,main函数一定在导入的包的init函数之后执行。
    • 因为包级别的变量在同一个文件中是按照声明顺序逐个初始化的,同一个包下的多个文件,会按照文件名的排列顺序进行初始化。这个顺序是由Go语言规范定义的。
  • 启动 goroutine 的 go 语句的执行,一定 happens before 此 goroutine 内的代码执行。
  • channel,往channel中发送一条数据,通常对应着另一个goroutine从这个参数里接受一条数据。
    1. 往 Channel 中的发送操作,happens before 从该 Channel 接收相应数据的动作完成之前,即第 n 个 send 一定 happens before 第 n 个 receive 的完成。
    2. close 一个 Channel 的调用,肯定 happens before 从关闭的 Channel 中读取出一个零值。
    3. 对于 unbuffered 的 Channel,也就是容量是 0 的 Channel,从此 Channel 中读取数据的调用一定 happens before 往此 Channel 发送数据的调用完成。
    4. 如果 Channel 的容量是 m(m>0),那么,第 n 个 receive 一定 happens before 第 n+m 个 send 的完成。
  • Mutex/RWMutex,在锁已经被持有的情况下,新锁必须等旧锁返回
    1. 第 n 次的 m.Unlock 一定 happens before 第 n+1 m.Lock 方法的返回;
    2. 对于读写锁 RWMutex m,如果它的第 n 个 m.Lock 方法的调用已返回,那么它的第 n 个 m.Unlock 的方法调用一定 happens before 任何一个 m.RLock 方法调用的返回,只要这些 m.RLock 方法调用 happens after 第 n 次 m.Lock 的调用的返回。 这就可以保证,只有释放了持有的写锁,那些等待的读请求才能请求到读锁。
    3. 对于读写锁 RWMutex m,如果它的第 n 个 m.RLock 方法的调用已返回,那么它的第 k (k<=n)个成功的 m.RUnlock 方法的返回一定 happens before 任意的 m.RUnlockLock 方法调用,只要这些 m.Lock 方法调用 happens after 第 n 次 m.RLock。
  • WaitGroup,wait方法等到计数值归零之后才范围
  • Once,对于once.Do(f),f函数一定会在Do方法返回之前执行
  • atomic, 现阶段还是不要使用atomic来保证顺序性。

补充

内存屏障

内存屏障(memory barrier)又叫内存栅栏(memory fence),其目的就是用来阻挡CPU对指令的重排序。

X86的内存模型

在谈及CPU时,通常会把变量的读操作称为load,变量的写操作称为store。两两组合因而会出现4类读写操作:

  • LoadLoad屏障:保证前面的Load在后面的Load之1前完成
  • StoreStore屏障:保证前面的Store在后面的Store之前完成
  • LoadStore屏障:保证前面的Load在后面的Store之前完成
  • StoreLoad屏障:保证前面的Store在后面的Load之前完成。

内存屏障与MESI

CPU的内存屏障如果只是保证指令顺序不会乱,也未必会让程序执行符合预期。因为MESI为了提升性能,引入了Store BufferInvalidate Queue。所以内存屏障还有其他功能:

  • 写类型的内存屏障还能触发内存的强制更新,让Store Buffer中的数据立刻回写到内存中。

  • 读类型的内存屏障会让Invalidate Queue中的缓存行在后面的load之前全部标记为失效。

主要参考 go-内存模型与指令重排