查看原文
其他

Golang 并发编程核心篇 —— 内存可见性

奇伢 奇伢云存储 2021-09-08


  • 背景

  • 什么是内存可见性 ?

    • 多级缓存设计

    • 编译优化和 cpu 乱序执行

  • 什么是 happens-before ?

  • 为什么要有 happens-before 规则?

  • C 语言的内存可见性

    • `volatile` 关键字

    • memory barrier

  • Golang 的 happens-before

    • Initialization

    • Goroutine creation

    • Goroutine destruction

    • Channel communiaction

    • Locks

    • Once

    • 错误的例子

  • 总结


背景


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 规则,我们应用程序只有合理应用且遵守这些规则,才能保证并发的时候,内存可见性如我们预期。

划重点:这些 happens-before 是 Golang 给出的承诺保证,程序员只有遵守这些准则,就能保证内存可见性的正确。

happens-before 是 Golang 语言层面给你的语义承诺,举个例子:A happen-before B ,A 的执行结果对于 B 可见。但是请注意了,编译器和处理器在保证语义的前提下,做任何优化都是被允许的,也就是说从现实时间线来说,并不一点是 A 比 B 先执行,程序员不关注这个(也没法去关注),程序员只要关注代码的 happens-before 的语义没变即可。

Golang 在 5 个方面提供了 happens-before  的规则承诺。

注意,后面我们说 A happens-before B,等价于 A <= B,等价于 A 先于 B,等价于 A 结果可见于 B,“先于”说的是可见性,并不是严格的物理时间顺序,注意下区别,后面不再解释。

我们展开看下 Golang 具体是提供了那几条 happens-before 规则,按照场景和类型分类,具体的规则如下:

Initialization

官方描述

If a package p imports package q, the completion of q's init functions happens before the start of any of p's.

规则解释

import package  的时候,如果 package p 里面执行 import q ,那么逻辑顺序上 package q 的 init 函数执行先于 package p 后面执行任何其他代码。

举个例子

// package p
import "q"      // 1
import "x"      // 2

执行(2)的时候,package q 的 init 函数执行结果对(2)可见,换句话说,q 的 init 函数先执行,import "x"  后执行。

Goroutine creation

官方描述

The go statement that starts a new goroutine happens before the goroutine's execution begins.

规则解释

该规则说的是 goroutine 创建的场景,创建函数本身先于 goroutine 内的第一行代码执行。

举个例子

var a int

func main() {
 a = 1          // 1
 go func() {   // 2
  println(a)  // 3
 }()
}

按照这条 happens-before 规则:

  1. (2)的结果可见于(3),也就是 2 <= 3;
  2. (1)天然先于(2),有 1 <= 2

happens-before 属于一种偏序规则,具有传递性,所以 1<=2<=3,所以 golang 程序能保证在 println(a) 的时候,一定是执行了 a=1 的指令的。再换句话说,主 goroutine 修改了 a 的值,这个结果在 协程里 println 的时候是可见的。

Goroutine destruction

官方描述

The exit of a goroutine is not guaranteed to happen before any event in the program.

规则解释

该规则说的是 goroutine 销毁的场景,这条规则说的是,goroutine 的 exit 事件并不保证先于所有的程序内的其他事件,或者说某个 goroutine exit 的结果并不保证对其他人可见。

举个例子

var a string

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

换句话说,协程的销毁流程本身没有做任何 happens-before 承诺。上面的实例里面的 goroutine 的退出结果不可见于下面的任何一行代码。

Channel communiaction

官方描述

  1. A send on a channel happens before the corresponding receive from that channel completes.
  2. The closing of a channel happens before a receive that returns a zero value because the channel is closed.
  3. A receive from an unbuffered channel happens before the send on that channel completes.
  4. The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes.

规则解释

channel 涉及到的 happens-before 规则一共有 4 条,数量最多。从这个条数我们也能看出来,Golang 里面 channel 才是保证内存可见性,有序性,代码同步的一等选择,我们一条条解析下:

规则一解释:

A send on a channel happens before the corresponding receive from that channel completes.

channel 的元素写入(send) 可见于 对应的元素读取(receive)操作完成。注意关键字:“对应的元素,指的是同一个元素”,说的是同一个元素哈。换句话说,一个元素的 send 操作先于这个元素 receive 调用完成(结果返回)。

举个例子

var c = make(chan int10)
var a string

func f() {
 a = "hello, world"  // A
 c <- 0                  // B
}

func main() {
 go f()              // C
 <-c                 // D
 print(a)            // E
}

这个例子能确保主协程打印出 “hello, world”字符串,也就是说 a="hello, world" 的赋值写可见于 print(a) 这条语句。我们由 happens-before 规则推导下:

  1. C <= A :协程 create 时候的 happens-before 规则承诺;
  2. A <= B :单协程上下文,指令顺序,天然保证;
  3. B <= D :send 0 这个操作先于 0 出队完成( <-c ) ,这条正是本规则;
  4. D <= E :单协程上下文,指令顺序,天然保证;

所以整个逻辑可见性执行的顺序是:C <= A <= B <= D <= E ,根据传递性,即 A <= E ,所以 print(a) 的时候,必定是 a 已经赋值之后。

规则二解释:

The closing of a channel happens before a receive that returns a zero value because the channel is closed.

channel 的关闭(还未完成)行为可见于 channel receive 返回( 返回 0, 因为 channel closed 导致 );

举个例子:

以下这个例子和上面的非常相似,只需要把 c<- 0 替换成 close(c) ,推到的过程也是完全一致的。

var c = make(chan int10)
var a string

func f() {
 a = "hello, world"  // A
    close(c)            // B
}

func main() {
 go f()              // C
 <-c                 // D
 print(a)            // E
}

整个逻辑可见性执行的顺序是:C <= A <= B <= D <= E ,根据传递性,A <= E ,所以 print(a) 的时候,必定是 a  已经赋值之后,所以也可正确打印“hello, world”。

规则三解释:

A receive from an unbuffered channel happens before the send on that channel completes.

第三条规则是针对 no buffer 的 channel 的,no buffer 的 channel 的 receive 操作可见于 send 元素操作完成。

举个例子:

var c = make(chan int)
var a string

func f() {
    a = "hello, world"      // A
    <-c                     // B
}

func main() {
    go f()                  // C
    c <- 0                  // D
    print(a)                // E
}
  1. C <= A :协程 create 时候的 happens-before 规则承诺;
  2. A <= B :单协程上下文,指令顺序,天然保证;
  3. B <= D :receive 操作可见于 0 入队完成( c<-0 ),换句话说,执行 D 的时候 B 操作已经执行了 ,这条正是本规则;
  4. D <= E :单协程上下文,指令顺序,天然保证;

所以,整个可见性执行的顺序是:C <= A <= B <= D <= E ,根据传递性,A <= E ,所以 print(a) 的时候,必定是 a  已经赋值之后,所以也可正确打印“hello, world”。

golang 为了确保这个 happens-before 规则,就算当物理时间先执行到 c<-0 这一行,是会让等待的,等待 D 看到 B 的执行,满足了这条规则才会让 c <- 0 返回,这样就能做到正确的可见性了。

注意,这条规则只适用于 no buffer 的 channel,如果上面的例子换成有 buffer 的 channel var c = make(chan int, 1) ,那么该程序将不能保证 print(a) 的时候打印 “hello,world”。

规则四解释:

The kth receive on a channel with capacity C happens before the k+Cth send from that channel completes.

第四条规则是通用规则,说的是,如果 channel 的 ringbuffer 长度是 C ,那么第 K 个元素的 receive 操作先于 第 K+C 个元素 的 send 操作完成;

仔细思考下这条规则,当 C 等于 0 的时候,其实说的就是规则三,也就说 no buffer 的 channel 只是这条规则的一种特殊情况。

举个例子:

以上面的规则,先举一个简单形象的例子:

c := make(chan int3)
c <- 1 
c <- 2
c <- 3
c <- 4  // A
go func (){
    <-c     // B     
}()

B 操作结果可见于 A(或者说,逻辑上 B 先于 A 执行),那么如果时间上,先到了 A 这一行怎么办?就会等待,等待 B 执行完成,B 返回之后,A 才会唤醒且 send 操作完成。只有这样,才保证了本条 happens-before 规则的语义不变,保证 B 先于 A 执行。Golang 在 chan 的实现里保证了这一点。

通俗的来讲,对于这种 buffer channel,一个坑只能蹲一个人,第 K+C 个元素一定要等第 K 个元素腾位置,chan 内部就是一个 ringbuffer,本身就是一个环,所以第 K+C 个元素和第 K 个元素要的是指向同一个位置,必须是 [ The kth receive ] happens-before [ the k+Cth send from that channel completes. ]

那我们再仔细思考下,这就类似一种计数的信号量控制。

  • ringbuffer 当前里面的元素相当于当前的资源消耗;
  • channel 的 ringbuffer 容量大小相当于最大的资源数;
  • send 一个元素相当于 acquire 一个信号量;
  • receive 相当于 release 一个信号量(腾出一个坑位);
  • 由于 Golang 承诺的这条 happens-before 规则,指明了第 K 个元素和第 K + C (同坑位)的同步行为;

这个跟我们用来限制并发数的场景是一样的,再看一个官方的例子:

var limit = make(chan int3)

func main() {
 for _, w := range work {
  go func(w func()) {
   limit <- 1
   w()
   <-limit
  }(w)
 }
 select{}
}

这个不就是一个限制 3 并发的用法嘛。

Locks

官方描述

  1. For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() happens before call m of l.Lock() returns.
  2. For any call to l.RLock on a sync.RWMutex variable l, there is an n such that the l.RLock happens (returns) after call n to l.Unlock and the matching l.RUnlock happens before call n+1 to l.Lock.

规则解释

锁相关的 happens-before 规则有两条。

规则一解释:

For any sync.Mutex or sync.RWMutex variable l and n < m, call n of l.Unlock() happens before call m of l.Lock() returns.

该规则针对任意 sync.Mutexsync.RWMutex 的锁变量 L,第 n 次调用 L.Unlock()  逻辑先于(结果可见于)第 m 次调用 L.Lock() 操作。

举个例子

var l sync.Mutex
var a string

func f() {
    a = "hello, world"  // E
    l.Unlock()          // F
}

func main() {
    l.Lock()        // A 
    go f()          // B
    l.Lock()        // C
    print(a)        // D
}

这个例子推导:

  1. A <= B (单协程上下文指令顺序,天然保证)
  2. B <= E (协程创建场景的 happens-before 承诺,见上)
  3. E <= F
  4. F <= C (这个就是本规则了,承诺了第 1 次的解锁可见于第二次的加锁,golang 为了守住这个承诺,所以必须让 C 行等待着 F 执行完)
  5. C <= D

所以整体的逻辑顺序链条:A <= B <= E <= F <= C <= D,推导出 E <= D,所以 print(a)  的时候 a 是一定赋值了“hello, world”的,该程序允许符合预期。

这条规则规定的是加锁和解锁的一个逻辑关系,讲的是谁等谁的关系。该例子讲的就是 golang 为了遵守这个承诺,保证 C 等 F。

规则二解释:

For any call to l.RLock on a sync.RWMutex variable l, there is an n such that the l.RLock happens (returns) after call n to l.Unlock and the matching l.RUnlock happens before call n+1 to l.Lock.

这个第二条规则是针对 sync.RWMutex 类型的锁变量 L,说的是 L.Unlock( ) 可见于 L.Rlock( ) ,第 n 次的 L.Runlock( ) 先于 第 n+1 次的 L.Lock()

我换一个角度说,两个方面:

  1. L.Unlock 会唤醒其他等待的读锁(L.Rlock( ) )请求;
  2. L.RUnlock 会唤醒其他 L.Lock( ) 请求

Once

官方描述:

A single call of f() from once.Do(f) happens (returns) before any call of once.Do(f) returns.

规则解释

该规则说的是 f( ) 函数执行先于 once.Do(f) 的返回。换句话说,f( ) 必定先执行完,once.Do(f)  函数调用才能返回。

举个例子

var a string
var once sync.Once

func setup() {
    a = "hello, world"  // A
}

func doprint() {
    once.Do(setup)      // B
    print(a)            // C
}

func twoprint() {
    go doprint()        // D
    go doprint()        // E
}

该例子保证了 setup( ) 一定先执行完 once.Do(setup) 调用才会返回,所以等到 print(a) 的时候,a 肯定是赋值了的。所以以上程序会打印两次“hello, world”。

错误的例子

大家仔细理解下面错误的例子,再思考下 happens-before 代表的含义,happens-before 指的是结果可见于,而非时间顺序。

var a string
var done bool

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

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

这个例子,很并不能确保打印 “hello, world”,print(a)  执行的时候 a 是可能没有赋值的,没有任何规则保证 A <= B 这个顺序。

  • a="hello, world"done=true  这两行代码的实际执行顺序并没有任何保证哈,cpu 是可以乱序执行的;
  • 更糟糕的,main 程序很有可能会死循环,因为 done  的赋值是在另一个并发的 goroutine 里,并且没有确保被 main 函数可见;

goroutine: setup 和 goroutine: main 这两者之间的没有存在承诺的规则,无法保证 A <= B 这个可见性。

怎么修正它?

我们再往深走一点,就拿上面的例子来讲,如果我要满足正确的可见性,怎么办?活学活用,想到了 no buffer channel 有一条规则:

A receive from an unbuffered channel happens before the send on that channel completes.

var a string
var done bool
var c = make(chan int)

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

 <-c // C
}

func main() {
 go setup()

 c <- 0 // D

 for !done {
 }
 print(a) // B
}

原先 setup 所在的 goroutine 和 main 所在的 goroutine 这两个 goroutines 不存在可见性保证,所以才导致这个问题,我们只需要在合适的位置加上可见性保证,如上例,只需要加上 C 和 D 两个卡点,这条规则是 golang 明确承诺的 happens-before 规则(no buffer channel 的 receive happens-before send 操作完成),推导:

  1. C <= D;
  2. 由于 A <= C ,D <= B;

根据传递性推导出 A <= C <= D <= B,推出有 A <= B ,所以必能保证正确的并发逻辑,print(a)  的时候 a 是已经赋值的。


总结


  1. 内存可见性是程序的通用问题,无论哪个语言都会有考虑和处理,区别只在于处理形式;
  2. c 处理的比较原始,使用 volatile 关键字禁止编译优化,使用内存屏障禁止编译优化和运行指令优化;
  3. happens-before 是更高层面的东西,是编程语言明确保证的因果关系(逻辑结果先后关系),多个高级语言都有此章节,golang,java 等大同小异;
  4. happens-before 不是指时间的执行顺序,而是指结果可见性,这一点非常重要;
  5. golang 提供了关于 package init,goroutine create,goroutine destroy,channel communication,locks,once 五个方面的 happens-before 规则,其中 channel 和 locks 涉及到的最多,所以 channel,locks 也是最常见保证内存正确可见性的手段;
  6. golang 程序员只需要对照语言承诺的 happens-before 规则,实现正确的并发程序就可以保证正确的可见性,而编译器和 cpu 处理器在此有限的 happens-before 规则限制内可以自由发挥,尽情优化逻辑。这样两者得到较好的平衡;
  7. 本文的大部分代码实例都来自于官方例子,非常经典和适用理解;

往期推荐




往期推荐



golang 内存管理分析

Golang 数据结构到底是怎么回事?gdb调一调?

Go 最细节篇 — chan 为啥没有判断 close 的接口 ?

golang  chan 最详细原理剖析,全面源码分析!看完不可能不懂的!





坚持思考,方向比努力更重要。关注我:奇伢云存储

来都来了,点个“在看”再走叭~~             

: . Video Mini Program Like ,轻点两下取消赞 Wow ,轻点两下取消在看

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存