Golang
主页 > 脚本 > Golang >

Golang内存模型The Go Memory Model

2022-11-12 | 佚名 | 点击:

本文翻译了原文并加入了自己的理解。

主要介绍多个 Go协程之间对同一个变量并发读写时需要注意的同步措施和执行顺序问题。并列出几个常见错误。

Go 内存模型涉及到多个 Go协程之间对同一个变量的读写。

假如有一个变量,其中一个 Go协程(a) 写这个变量,另一个 Go协程(b) 读这个变量;Go 内存模型定义了什么情况下 Go协程(b) 能够确保读取到由 Go协程(a) 写入的值。

2. 建议(Advice)

3. 发生在…之前(Happens Before)

除了重排序需要理解,其余概念其实没那么重要,看后面的例子就懂了。

3.1 重排序

当只有一个 Go协程时,对同一个变量的读写必然是按照代码编写的顺序来执行的。对于多个变量的读写,如果重新排序不影响代码逻辑的正常执行,编译器和处理器可能会对多个变量的读写过程重新排序。

比如对于 a = 1; b = 2 这两个语句,在同一个 Go协程里先执行 哪个其实是没有区别的,只要最后执行结果正确就行。

1

2

3

a := 1//1

b := 2//2

c := a + b //3

但是,因为重新排列执行顺序的情况的存在,会导致**某个 Go协程所观察到的执行顺序可能与另一个 Go协程观察到的执行顺序不一样。**可能另一个 Go协程 观察到的事实是 b 的值先被更新,而 a 的值被后更新。

3.2 happens-before

为了表征读写需求,我们可以定义 happens-before,用来表示 Go 语言中某一小段内存命令的执行顺序。

3.3 规则

在只有一个 Go协程的内部,happens-before的顺序就是代码显式定义的顺序。当 Go协程 不仅仅局限在一个的时候,存在下面两个规则:

规则二的条件比规则一的条件更为严格,它要求没有其他的写操作和 w、r 并发地发生。

在一个 Go协程 里是不存在并发的,因此规则一和规则二是等效的:读操作 r 可以观察到最近一次写操作 w 写入的值。

但是,当多个协程访问一个共享变量时,就必须使用同步事件来构建 happens-before 的条件,从而保证读操作观察到的一定是想要的写操作。

在内存模型中,变量 v 的零值初始化操作等同于一个写操作。

如果变量的值大于单机器字(CPU 从内存单次读取的字节数),那么 CPU 在读和写这个变量的时候是以一种不可预知顺序的多次执行单机器字的操作,这也是 sync/atomic 包存在的价值。

4. 同步(Synchronization)

4.1 初始化(Initialization)

程序的初始化是在一个单独的 Go协程 中进行的,但是这个协程可以创建其他的 Go协程 并且二者并发执行。

每个包都允许有一个 init 函数,当这个包被导入时,会执行该包的这个 init 函数,做一些初始化任务。

4.2 Go协程的创建(Goroutine creation)

通过 go 语句启动新的 Go协程这个动作,发生在新的 Go协程的执行之前。比如下面的例子:

1

2

3

4

5

6

7

8

var a string

func f() {

  print(a)

}

func hello() {

  a = "hello, world"

  go f()

}

调用函数 hello 会在调用后的某个时间点打印 “hello, world” ,这个时间点可能在 hello 函数返回之前,也可能在 hello 函数返回之后。

4.3 Go协程的销毁(Goroutine destruction)

Go协程的退出无法确保发生在程序的某个事件之前。比如下面的例子:

1

2

3

4

5

var a string

func hello() {

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

    print(a)

}

其中 a 的赋值语句没有任何的同步措施,因此无法保证被其他任意的 Go 协程(例如 hello 函数本身)观察到这个赋值事件的存在。

一些激进的编译器可能会在编译阶段删除上面代码中的整个 go 语句。

如果某个 Go协程 里发生的事件必须要被另一个 Go协程 观察到,需要使用同步机制进行保证,比如使用锁或者信道(channel)通信来构建一个相对的事件发生顺序。

4.4 信道通信(Channel communication)

这部分介绍通过 channel 实现并发顺序控制。

有缓存channel

信道通信是多个 Go协程 间事件同步的主要方式。在某个特定的信道上发送一个数据,则对应地可以在这个信道上接收一个数据,一般情况下是在不同的 Go协程 间发送与接收。

1

2

3

4

5

6

7

8

9

10

11

var c = make(chan int, 10)

var a string

func f() {

  a = "hello, world"

  c <- 0

}

func main() {

  go f()

  <-c

  print(a)

}

 上面这段代码保证了 `hello, world` 的打印。因为信道的写入事件 `c <- 0` 发生在读取事件 `<-c` 之前,而 `<-c` 发生在 `print(a)`之前。信道未被读取时协程会阻塞。

无缓存 channel

1

2

3

4

5

6

7

8

9

10

11

var c = make(chan int) //容量为0,无缓存

var a string

func f() {

  a = "hello, world"

  <-c

}

func main() {

  go f()

  c <- 0

  print(a)

}

上面这段代码依然可以保证可以打印 `hello, world`。因为信道的写入事件 `c <- 0` 发生在读取事件 `<-c` 之前,而 `<-c` 发生在写入事件 `c <- 0` 完成之前,同时写入事件 `c <- 0` 的完成发生在 `print` 之前。

上面的代码,如果信道是带缓存的(比如 `c = make(chan int, 1)`),程序将不能保证会打印出 `hello, world`,它可能会打印出空字符串,也可能崩溃退出,或者表现出一些其他的症状。

规则抽象

1

2

3

4

5

6

7

8

9

10

11

var limit = make(chan int, 3)

func main() {

  for _, w := range work {

    go func(w func()) {

      limit <- 1  // channel里达到3个即阻塞

      w()

      <-limit  // 取出后channel里小于3个即可继续

    }(w)

  }

  select{}

}

4. 锁

包 sync 实现了两类锁数据类型,分别是 sync.Mutex 和 sync.RWMutex,即互斥锁和读写锁。

即先解开上一次锁才能上这一次锁。

比如下面的代码:

1

2

3

4

5

6

7

8

9

10

11

12

var l sync.Mutex

var a string

func f() {

  a = "hello, world"

  l.Unlock()

}

func main() {

  l.Lock()

  go f()

  l.Lock()

  print(a)

}

上面这段代码保证能够打印 `hello, world`。`l.Unlock()`的第 1 次调用返回(在函数 f 内部)发生在 `l.Lock()` 的第 2 次调用返回之前,后者发生在 `print` 之前。 

5. 单次运行

包 sync 还提供了 Once 类型用来保证多协程的初始化的安全。

多个 Go协程 可以并发执行 once.Do(f) 来执行函数 f, 且只会有一个 Go协程会运行 f(),其他的 Go 协程会阻塞到 f() 运行结束(不再执行 f,但能得到运行结果)

比如下面的代码:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

func setup() {

    time.Sleep(time.Second * 2) //1

    a = "hello, world"

    fmt.Println("setup over") //2

}

func doprint() {

    once.Do(setup) //3

    fmt.Println(a) //4

    wg.Done()

}

func twoprint() {

    go doprint()

    go doprint()

}

func main() {

    wg.Add(2)

    twoprint()

    wg.Wait()

}

setup over

hello, world

hello, world

6. 不正确的同步方式

6.1 案例一

对某个变量的读操作 r 一定概率可以观察到对同一个变量的并发写操作 w,但是即使这件事情发生了,也并不意味着发生在 r 之后的其他读操作可以观察到发生在 w 之前的其他写操作。(这里的先后指的是代码里面声明的操作的先后顺序,而不是实际执行时候的)

比如下面的代码:

1

2

3

4

5

6

7

8

9

10

11

12

13

var a, b int

func f() {

  a = 1

  b = 2

}

func g() {

  print(b)

  print(a)

}

func main() {

  go f()

  g()

}

上面的代码里函数 g 可能会先打印 2(b的值),然后打印 0(a的值)。可能大家会认为既然 b 的值已经被赋值为 2 了,那么 a 的值肯定被赋值为 1 了,但事实是两个事件的先后在这里是没有办法确定的,因为编译器会改变执行顺序。

上面的事实可以证明下面的几个常见的错误。

6.2 案例二

双重检查锁定尝试避免同步带来的开销。比如下面的例子,twoprint 函数可能会被错误地编写为:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

var a string

var done bool

func setup() {

  a = "hello, world"

  done = true

}

func doprint() {

  if !done {

    once.Do(setup)

  }

  print(a)

}

func twoprint() {

  go doprint()

  go doprint()

}

在 doprint 函数中,观察到对 done 的写操作并不意味着能够观察到对 a 的写操作。上面的写法依然有可能打印出空字符串。

6.3 案例三

另一个常见的错误用法是对某个值的循环检查,比如下面的代码:

1

2

3

4

5

6

7

8

9

10

11

12

var a string

var done bool

func setup() {

    a = "hello, world"

    done = true

}

func main() {

    go setup()

    for !done {

    }

    print(a)

}

和上一个例子类似,main函数中观察到对 done 的写操作并不意味着可以观察到对 a 的写操作,因此上面的代码依然可能会打印出空字符串。

更糟糕的是,由于两个 Go协程之间缺少同步事件,main 函数甚至可能永远无法观察到对 done 变量的写操作,导致 main 中的 for 循环永远执行下去。

上面这个错误有一种变体,如下面的代码所示:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

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 函数观察到 g != nil并且退出了它的 for 循环,依然没有办法保证它可以观察到被初始化的 g.msg 值。

避免上面几个错误用法的方式是一样的:显式使用同步语句。

7. 总结

通过上面所有的例子,不难看出解决多goroutine下共享数据可见性问题的方法是在访问共享数据时候施加一定的同步措施。

原文链接:https://zhuanlan.zhihu.com/p/506088064
相关文章
最新更新