Go如何保证并发读写的顺序?—内存模型

Posted 技术能量站

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Go如何保证并发读写的顺序?—内存模型相关的知识,希望对你有一定的参考价值。

1、背景

Go 的内存模型 并不是指 Go 对象的内存分配、内存回收和内存整理的规范,它描述的是并发环境中多 goroutine 读相同变量的时候,变量的可见性条件。具体点说,就是指,在什么条件下,goroutine 在读取一个变量的值的时候,能够看到其它 goroutine 对这个变量进行的写的结果。

由于 CPU 指令重排和多级 Cache 的存在,保证多核访问同一个变量这件事儿变得非常复杂。毕竟,不同 CPU 架构(x86/amd64、ARM、Power 等)的处理方式也不一样,再加上编译器的优化也可能对指令进行重排,所以编程语言需要一个规范,来明确多线程同时访问同一个变量的可见性和顺序,在编程语言中,这个规范被叫做内存模型。

除了 Go,Java、C++、C、C#、Rust 等编程语言也有内存模型。为什么这些编程语言都要定义内存模型呢?在我看来,主要是两个目的。

  • 向广大的程序员提供一种保证,以便他们在做设计和开发程序时,面对同一个数据同时被多个 goroutine 访问的情况,可以做一些串行化访问的控制,比如使用 Channel 或者 sync 包和 sync/atomic 包中的并发原语。
  • 允许编译器和硬件对程序做一些优化。这一点其实主要是为编译器开发者提供的保证,这样可以方便他们对 Go 的编译器做优化。

既然内存模型这么重要,首先,我们要先弄明白重排和可见性的问题,因为它们影响着程序实际执行的顺序关系。

2、重排和可见性的问题

由于指令重排,代码并不一定会按照你写的顺序执行。

举个例子

当两个 goroutine 同时对一个数据进行读写时,假设 goroutine g1 对这个变量进行写操作 w,goroutine g2 同时对这个变量进行读操作 r,那么,如果 g2 在执行读操作 r 的时候,已经看到了 g1 写操作 w 的结果,那么,也不意味着 g2 能看到在 w 之前的其它的写操作。这是一个反直观的结果,不过的确可能会存在。

接下来,我再举几个具体的例子,带你来感受一下,重排以及多核 CPU 并发执行导致程序的运行和代码的书写顺序不一样的情况。

先看第一个例子,代码如下:


var a, b int

func f() 
  a = 1 // w之前的写操作
  b = 2 // 写操作w


func g() 
  print(b) // 读操作r
  print(a) // ???


func main() 
  go f() //g1
  g() //g2

可以看到,打印结果可能是 00、01等,第 9 行是要打印 b 的值。需要注意的是,即使这里打印出的值是 2,但是依然可能在打印 a 的值时,打印出初始值 0,而不是 1。这是因为,程序运行的时候,不能保证 g2 看到的 a 和 b 的赋值有先后关系。

再来看一个类似的例子。


var a string
var done bool

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


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

在这段代码中,主 goroutine main 即使观察到 done 变成 true 了,最后读取到的 a 的值仍然可能为空。

更糟糕的情况是,main 根本就观察不到另一个 goroutine 对 done 的写操作,这就会导致 main 程序一直被 hang 住。甚至可能还会出现半初始化的情况,比如:


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 goroutine 观察到 g 不为 nil,也可能打印出空的 msg(第 17 行)。

看到这里,你可能要说了,我都运行这个程序几百万次了,怎么也没有观察到这种现象?我可以这么告诉你,能不能观察到和提供保证(guarantee)是两码事儿。由于 CPU 架构和 Go 编译器的不同,即使你运行程序时没有遇到这些现象,也不代表 Go 可以 100% 保证不会出现这些问题。

刚刚说了,程序在运行的时候,两个操作的顺序可能不会得到保证,那该怎么办呢?接下来,我要带你了解一下 Go 内存模型中很重要的一个概念:happens-before,这是用来描述两个时间的顺序关系的。如果某些操作能提供 happens-before 关系,那么,我们就可以 100% 保证它们之间的顺序。

3、happens-before

在一个 goroutine 内部,程序的执行顺序和它们的代码指定的顺序是一样的,即使编译器或者 CPU 重排了读写顺序,从行为上来看,也和代码指定的顺序一样。这是一个非常重要的保证,我们一定要记住。

这是一个非常重要的保证,我们一定要记住。

我们来看一个例子。在下面的代码中,即使编译器或者 CPU 对 a、b、c 的初始化进行了重排,但是打印结果依然能保证是 1、2、3,而不会出现 1、0、0 或 1、0、1 等情况。

func foo() 
    var a = 1
    var b = 2
    var c = 3

    println(a)
    println(b)
    println(c)

但是,对于另一个 goroutine 来说,重排却会产生非常大的影响。因为 Go 只保证 goroutine 内部重排对读写的顺序没有影响,比如刚刚我们在讲“可见性”问题时提到的三个例子,那该怎么办呢?这就要用到 happens-before 关系了。

如果两个 action(read 或者 write)有明确的 happens-before 关系,你就可以确定它们之间的执行顺序(或者是行为表现上的顺序)。

Go 内存模型通过 happens-before 定义两个事件(读、写 action)的顺序:如果事件 e1 happens before 事件 e2,那么,我们就可以说事件 e2 在事件 e1 之后发生(happens after)。如果 e1 不是 happens before e2, 同时也不 happens after e2,那么,我们就可以说事件 e1 和 e2 是同时发生的。

如果要保证对“变量 v 的读操作 r”能够观察到一个对“变量 v 的写操作 w”,并且 r 只能观察到 w 对变量 v 的写,没有其它对 v 的写操作,也就是说,我们要保证 r 绝对能观察到 w 操作的结果,那么就需要同时满足两个条件:

  1. w happens before r;
  2. 其它对 v 的写操作(w2、w3、w4, …) 要么 happens before w,要么 happens after r,绝对不会和 w、r 同时发生,或者是在它们之间发生。

你可能会说,这是很显然的事情啊,但我要和你说的是,这是一个非常严格、严谨的数学定义。

对于单个的 goroutine 来说,它有一个特殊的 happens-before 关系,Go 内存模型中是这么讲的:

Within a single goroutine, the happens-before order is the order expressed by the program.

我来解释下这句话。它的意思是,在单个的 goroutine 内部, happens-before 的关系和代码编写的顺序是一致的。

在 goroutine 内部对一个局部变量 v 的读,一定能观察到最近一次对这个局部变量 v 的写。如果要保证多个 goroutine 之间对一个共享变量的读写顺序,在 Go 语言中,可以使用并发原语为读写操作建立 happens-before 关系,这样就可以保证顺序了。

说到这儿,我想先给你补充三个 Go 语言中和内存模型有关的小知识,掌握了这些,你就能更好地理解下面的内容。

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

接下来,我就带你学习下 Go 语言中提供的 happens-before 关系保证。

4、Go 语言中保证的 happens-before 关系

除了单个 goroutine 内部提供的 happens-before 保证,Go 语言中还提供了一些其它的 happens-before 关系的保证,下面我来一个一个介绍下。

4.1 init 函数

应用程序的初始化是在单一的 goroutine 执行的。如果包 p 导入了包 q,那么,q 的 init 函数的执行一定 happens before p 的任何初始化代码。

这里有一个特殊情况需要你记住:main 函数一定在导入的包的 init 函数之后执行

包级别的变量在同一个文件中是按照声明顺序逐个初始化的,除非初始化它的时候依赖其它的变量。同一个包下的多个文件,会按照文件名的排列顺序进行初始化。这个顺序被定义在Go 语言规范中,而不是 Go 的内存模型规范中。你可以看看下面的例子中各个变量的值:

var (
  a = c + b  // == 9
  b = f()    // == 4
  c = f()    // == 5
  d = 3      // == 5 全部初始化完成后
)

func f() int 
  d++
  return d

具体怎么对这些变量进行初始化呢?Go 采用的是依赖分析技术。不过,依赖分析技术保证的顺序只是针对同一包下的变量,而且,只有引用关系是本包变量、函数和非接口的方法,才能保证它们的顺序性。

同一个包下可以有多个 init 函数,甚至一个文件中也可以包含多个相同签名的 init 函数。

刚刚讲的这些都是不同包的 init 函数执行顺序,下面我举一个具体的例子,把这些内容串起来,你一看就明白了。

这个例子是一个 main 程序,它依赖包 p1,包 p1 依赖包 p2,包 p2 依赖 p3。

为了追踪初始化过程,并输出有意义的日志,我定义了一个辅助方法,打印出日志并返回一个用来初始化的整数值:

func Trace(t string, v int) int 
    fmt.Println(t, ":", v)
    return v

包 p3 包含两个文件,分别定义了一个 init 函数。第一个文件中定义了两个变量,这两个变量的值还会在 init 函数中进行修改。
我们来分别看下包 p3 的这两个文件:

// lib1.go in p3

var V1_p3 = trace.Trace("init v1_p3", 3)
var V2_p3 = trace.Trace("init v2_p3", 3)


func init() 
    fmt.Println("init func in p3")
    V1_p3 = 300
    V2_p3 = 300

// lib2.go in p3

func init() 
    fmt.Println("another init func in p3")

下面再来看看包 p2。包 p2 定义了变量和 init 函数。第一个变量初始化为 2,并在 init 函数中更改为 200。第二个变量是复制的 p3.V2_p3。

var V1_p2 = trace.Trace("init v1_p2", 2)
var V2_p2 = trace.Trace("init v2_p2", p3.V2_p3)

func init() 
    fmt.Println("init func in p2")
    V1_p2 = 200

包 p1 定义了变量和 init 函数。它的两个变量的值是复制的 p2 对应的两个变量值。

var V1_p1 = trace.Trace("init v1_p1", p2.V1_p2)
var V2_p1 = trace.Trace("init v2_p1", p2.V2_p2)

func init() 
    fmt.Println("init func in p1")

main 定义了 init 函数和 main 函数。


func init() 
    fmt.Println("init func in main")



func main() 
    fmt.Println("V1_p1:", p1.V1_p1)
    fmt.Println("V2_p1:", p1.V2_p1)

运行 main 函数会依次输出 p3、p2、p1、main 的初始化变量时的日志(变量初始化时的日志和 init 函数调用时的日志):

// 包p3的变量初始化
init v1_p3 : 3
init v2_p3 : 3
// p3的init函数
init func in p3
// p3的另一个init函数 
another init func in p3

// 包p2的变量初始化
init v1_p2 : 2
init v2_p2 : 300
// 包p2的init函数
init func in p2

// 包p1的变量初始化
init v1_p1 : 200
init v2_p1 : 300
// 包p1的init函数
init func in p1

// 包main的init函数
init func in main
// main函数
V1_p1: 200
V2_p1: 300

下面,我们再来看看 goroutine 对 happens-before 关系的保证情况。

4.2 goroutine

首先,我们需要明确一个规则:启动 goroutine 的 go 语句的执行,一定 happens before 此 goroutine 内的代码执行。
根据这个规则,我们就可以知道,如果 go 语句传入的参数是一个函数执行的结果,那么,这个函数一定先于 goroutine 内部的代码被执行。
所以,如果你想观察某个 goroutine 的执行效果,你需要使用同步机制建立 happens-before 关系,比如 Mutex 或者 Channel。接下来,我会讲 Channel 的 happens-before 的关系保证。

4.3 Channel

Channel 是 goroutine 同步交流的主要方法。往一个 Channel 中发送一条数据,通常对应着另一个 goroutine 从这个 Channel 中接收一条数据。
通用的 Channel happens-before 关系保证有 4 条规则

  • 第 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 的完成。

4.4 Mutex/RWMutex

对于互斥锁 Mutex m 或者读写锁 RWMutex m,有 3 条 happens-before 关系的保证。

  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。

4.5 WaitGroup

接下来是 WaitGroup 的保证。
对于一个 WaitGroup 实例 wg,在某个时刻 t0 时,它的计数值已经不是零了,假如 t0 时刻之后调用了一系列的 wg.Add(n) 或者 wg.Done(),并且只有最后一次调用 wg 的计数值变为了 0,那么,可以保证这些 wg.Add 或者 wg.Done() 一定 happens before t0 时刻之后调用的 wg.Wait 方法的返回。

这个保证的通俗说法,就是 Wait 方法等到计数值归零之后才返回。

4.6 Once

它提供的保证是:对于 once.Do(f) 调用,f 函数的那个单次调用一定 happens before 任何 once.Do(f) 调用的返回。换句话说,就是函数 f 一定会在 Do 方法返回之前执行。

4.7 atomic

其实,Go 内存模型的官方文档并没有明确给出 atomic 的保证,有一个相关的 issue go# 5045记录了相关的讨论。光看 issue 号,就知道这个讨论由来已久了。Russ Cox 想让 atomic 有一个弱保证,这样可以为以后留下充足的可扩展空间,所以,Go 内存模型规范上并没有严格的定义。

5. 总结

谨慎地使用这些保证,能够让你的程序按照设想的 happens-before 关系执行,但是不要以为完全理解这些概念和保证,就可以随意地制造所谓的各种技巧,否则就很容易掉进“坑”里,而且会给代码埋下了很多的“定时炸弹”。
比如,Go 里面已经有值得信赖的互斥锁了,如果没有额外的需求,就不要使用 Channel 创造出自己的互斥锁。
当然,我也不希望你畏手畏脚地把思想局限住,我还是建议你去做一些有意义的尝试,比如使用 Channel 实现信号量等扩展并发原语。

以上是关于Go如何保证并发读写的顺序?—内存模型的主要内容,如果未能解决你的问题,请参考以下文章

java内存模型详解

Java内存模型(JMM)总结与学习

Java 并发编程面试题

Go CSP并发模型

并发基础之Java内存模型JMM

Golang面向并发的内存模型