深度剖析 golang 的内存可见性

更多干货:关注公众号 奇伢云存储

背景

Go 语言最大的特殊就是高并发能力,以 Goroutine 协程为执行体充分利用现代处理器的计算能力,但是并发机制也带来了协程并发安全的问题。现代处理器都是多级缓存的结构,并且编译器会对指令进行重排序和优化,cpu 执行也可能乱序执行,那么如何保证一个协程执行体写操作被另一个执行体正确可见?Go 的内存模型( Go Memory Model )定义了一套 happens-before 准则,只有依赖于这个准则的程序,才能保证并发逻辑正确执行。

什么是内存可见性 ?

Golang 的内存可见性,估计很多 Golang 程序员自己都没注意过?其实在很多语言里面是有这方面的直接体现的,Golang 自然也有,那么什么是内存可见性?通俗来讲就是操作结果对别人可见。通常我们也只在并发环境才会讨论这个问题,单执行体环境这个前面执行的指令对后面的指令都是可见的。

举个例子:

a := 1          // A
b := 2          // B
c := a + b      // C

如上代码,经过编译器和编译优化和 cpu 的乱序执行,执行顺序可能并不是 (A,B,C) 这样执行,也可能是 (B,A,C),甚至(C,A,B),只要保证了程序的正确性,保证了我们上层代码的语义,我们允许编译器和 cpu 施展各自的手段去优化。就可见性来说,A 的结果对于 B 是可见的,B的结果对于 C 是可见的。

多级缓存设计

现代的处理器架构都是多级缓存的,cpu 有 L1,L2,L3 缓存,最后才是 DRAM,对于编译器生成的代码也是优先使用寄存器,其次才是主存。所以在并发场景下,必然是存在一致性问题的,一个执行体对变量的修改可能并不能立马对其他执行体可见。

编译优化和 cpu 乱序执行

编译器会对代码进行优化,包括代码的调整,重排等操作。cpu 执行指令也是以乱序执行指令的,这些都是为了性能的考虑。

什么是 happens-before ?

happens-before 是比 指令重排、内存屏障这些概念更上层的东西,是语言层面给我们的承诺。我们没有办法穷举在所有计算机架构下重排序会如何发生,也没办法为重排序定义该在什么时候插入屏障来阻止重排序、刷新 cache 的顺序,这个是无法做到的事情。

如何理解 Happens-Before 呢?如果直接翻译成“先行发生”,那就走远了,happens-before 并不是从物理时间上说前面一个操作发生在后续操作的前面,它真正要表达的是:前面一个操作的结果对后续操作是可见的(可以理解成逻辑上的操作顺序是有先后的)。happens-before 本质是一种偏序关系,所以要满足传递性。我们说 A happens-before B ,也就是说 A 的结果对于 B 是可见的,简称 A <= B,或者 hb(A, B)。

为什么要有 happens-before 规则?

  • 首先,如果 Go 语言提供屏障操作让 Go 开发者自己决定何时插入屏障,那么并发代码是难写且容易出错的(技术上是可行的,主要是太偏底层,比如 c 就是这样搞);
  • 再者,对于编译器和 cpu 来说,这些内存屏障,优化屏障都是约束,让底层无法随心所欲的进行优化,所以这些约束肯定是越少越好;

程序员希望不要底层搞那些花里胡哨的,最好别搞优化,对底层的约束越多越好,最好底层的指令就和我写的代码一模一样就好,但是编译器和 cpu 处理器却总喜欢搞些花的,希望上层对自己的约束越少越好。怎么平衡这个矛盾?

Go 的选择是为程序员提供了有限几条类似公理的规则,这个就是我们的 happens-before 规则(类似其他高级语言,也是类似的,比如 java )。Go 程序员开发的时候,只有遵守了这些规则,才能保证正确的语义,满足正确的可见性。这些规则以外的事情,不属于 Go 的承诺,自然结果也是未知。

编译器和 cpu 指令只需要保证这几条 happens-before 的规则语义不变,在此基础上做任何优化都是允许的。换句话说,happens-before 约束了编译器的优化行为,允许编译器优化,但是要求编译器优化后一定遵守 happens-before 规则。

C 语言的内存可见性

内存可见性是一个通用性质的问题,类似于 c/c++,golang,java 都存在相应的策略。作为比较,我们先思考下 c 语言,在 c 里面却几乎没有 happens-before 的理论规则,究其原因还是由于 c 太底层了,常见 c 的内存可见性一般用两个比较原始的手段来保证:

  • volatile 关键字(很多语言都有这个关键字,但是含义大相径庭,这里只讨论 c )
  • memory barrier

volatile 关键字

volatile 声明的变量不会被编译器优化掉,在访问的时候能确保从内存获取,否则很有可能变量的读写都暂时只在寄存器。但是,c 里面的 volatile 关键字并不能确保严格的 happens-before 关系,也不能阻止 cpu 指令的乱序执行,且 volatile 也不能保证原子操作。

vo 我们先以一个简单的常见的 c 代码举例:

// done 为全局变量
int done = 0;
while ( ! done ) {
   
    /* 循环内容 */
}

// done 的值,会在另一个线程里会修改成 1;

这段代码,编译器根据自己的优化理解,会在编译期间直接展开翻译成(或者每次都直接取寄存器里的值,寄存器里永远存的是 0 ):

while ( 1 ) {
   
    /*  循环内容 */
}

在编译期间就已经确认是个死循环了,但其实 done 的值在其他的线程里是会被修改的,所以这个与我们预期是不相符的,所以这种场景,变量 done 是要用 volatile
来修饰的,显示声明每次 done 的值一定要直达内存。

memory barrier

内存屏障(memory barrier),又叫做内存栅栏(memory fence),分为两类:

  1. 编译器屏障(又叫做优化屏障)—— 针对编译期间的代码生成
  2. cpu 内存屏障 —— 针对运行期间的指令运行

这两类屏障都可以在 c 里面可以手动插入,比如以下:

// 编译器屏障,只针对编译器生效(GCC 编译器的话,可以使用 __sync_synchronize)
#define barrier() __asm__ __volatile__("":::"memory")

// cpu 内存屏障
#define lfence() __asm__ __volatile__("lfence": : :"memory") 
#define sfence() __asm__ __volatile__("sfence": : :"memory") 
#define mfence() __asm__ __volatile__("mfence": : :"memory") 

优化屏障能阻止乱序代码生成和 cpu 内存屏障能阻止乱序指令执行。有很多人会奇怪了,我写 c 代码的时候,好像从来没有手动插入过内存屏障?其实 c 库的锁操作(比如 pthread_mutx_t )是天然自带屏障的。

小结:c 语言保证内存可见性的的方式非常简单和原始,几乎是在指令级别的操作考虑。

Golang 的 happens-before

happens-before 我们已经知道是什么东西了,本质上就是一个逻辑顺序的保证。happens-before 是比指令重排、内存屏障更上层的概念,由编程语言层面做的承诺。Golang 有少许几个承诺的 happens-before 规则,我们应用程序只有合理应用且遵守这些规则,才能保证并发的时候,内存可见性如我们预期。

划重点&

  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值