xhs_am

xhs_am

「Go语言内存模型」阅读和总结

背景介绍:(可以不看,直接跳过


最近在重学操作系统课程,对操作系统中的并发程序有了一定的了解,但是操作系统课程中的代码都是关于 C 语言的,课程中说,操作系统也不过是一个并发程序而已👍(譬如提供了条件变量和信号量来保证并发的正确性),而这个并发程序几乎是用 C 语句写成的,于是我就很好奇,Go 语言提供的并发编程模式到底是怎样的,它提供了哪些并发原语去保证并发的正确性呢?那么就让我们开始一览 Go 的内存模型吧。

写这篇文章之前,我在心里一直默念,jyy yyds!!!


本片几乎是 Go 语言内存模型的翻译版本,若你的英文较好,建议直接阅读一手资料,肯定比翻译版本有味道~

官方文档的链接:go memory model

data race (数据竞争)#

对一个内存位置的写入和对同一位置的读取或者写入同时发生,除非有sync/atomic包对数据进行保护,使得访问操作原子化

数据竞争主要是无法保证对数据的操作顺序

比如 goroutine1 对变量 a 进行写入操作 ,goroutine2对变量 a 进行读取操作

package main

import (
  "fmt"
  "time"
)

var a = 3

func g1() { // goroutine 1 执行
  a *= 2
  fmt.Println(a)
}

func g2() { // goroutine2 执行
  fmt.Println(a)
}

func main() {
  fmt.Println("init value: ", a)
  go g1()
  go g2()

  time.Sleep(time.Second)
}

如果使用go build -race hello.go 的话,执行二进制文件时,会抛出 warning:

WARNING: DATA RACE
Write at 0x000104d946d0 by goroutine 7:
  main.g1()
      hello.go:11 +0x3c

Previous read at 0x000104d946d0 by goroutine 8:
  main.g2()
      hello.go:16 +0x2c

Goroutine 7 (running) created at:
  main.main()
      hello.go:21 +0xa4

Goroutine 8 (finished) created at:
  main.main()
      hello.go:22 +0xb0
==================
6
Found 1 data race(s)

有可能 g1 先执行,也有可能 g2 先执行,这时候变量 a 的结果会变得不确定

Go 官方建议程序员使用适当的同步来避免数据竞争,在没有数据竞争的情况下,Go 程序的表现就像所有的 Goroutine 都被复用在了一个处理器上面,这一行为有时候被称为 DRF- SC(data-race-free program execute in a sequentially consistent manner)无数据竞争的程序以顺序一致的方式执行

虽然程序员应该写出无数据竞争的 Go 程序,但是 Go 语言对数据竞争还是做了一定的限制,Go 可以通过报告出数据竞争来终止程序。否则,Go 会观察当前内存位置处实际写入的值(这时候可能有多个 goroutine 并发写入)并且未被覆盖,Go 像 Java、JS 这种对数据竞争进行了约束,因为数据竞争总是有限的,而不像 C、C++,编译器可以做任何事情。

Go 通过一些工具可以诊断和报告数据竞争,并且认为数据竞争是错误的。

内存模型#

Go 语言内存模型的定义紧跟了 Hans-J. Boehm and Sarita V. Adve in “Foundations of the C++ Concurrency Memory Model”, published in PLDI 2008.

内存模型描述了对程序执行的要求,程序执行是由 goroutine 执行组成的,而 goroutine 又是由内存操作组成的。
一个内存操作是由四个细节模拟的

  • 它的种类:表明它是一个普通的数据读、一个普通的数据写,还是一个同步操作,比如一个原子数据访问,一个互斥操作、或者是一个 channel 操作
  • 它在程序中的位置
  • 被访问内存或者变量的位置
  • 该操作所读取或者写入的值

一些与读有关的内存操作,包括读、原子读、互斥锁和 channel 的发送和接收。有些操作,同时进行了读和写,比如原子的比较和交换(atomic compare and swap)「atomic CAS」

一个 goroutine 的执行被建模为一个 goroutine 对内存的一组操作

要求一
在每个 goroutine 中的内存操作必须与该 goroutine 的顺序执行一致,这个执行顺序必须符合先序关系,要符合 Go 语言规定的部分顺序关系,以及表达式的顺序

要求二
当限制在同步操作中,对于给定的程序执行,映射 W(写) 必须通过某种隐式的同步操作的全序来解释,这种顺序需要满足正确的顺序保证一致的内存访问。

在编写并发程序时,会涉及到共享内存的读写操作,这种情况下,需要保证对于同一个变量的多次访问之间存在正确的顺序。两个 goroutine 如果没有同步操作建立 happens-before 的关系,那么执行顺序是未定义的。


所以在编写并发程序时,要确保某个映射能够正确反映内存中的实际情况,读写操作必须按照正确的顺序执行,可以通过同步原语来保证共享变量的访问顺序按照正确的顺序进行。

synchronized before 是同步内存操作的一个偏序关系,从 W(写) 派生而来。如果一个同步的读操作 r 观测到一个同步的写操作 w 「也就是说,如果 w (r) = w, 那么写操作在读之前就同步了」。简单地说,synchronized before 是一个同步总顺序的子集,是一个同步操作的偏序,仅限于 W 直接观察到的结果。

happens before 关系被定义为 sequenced beforesynchronized before 的联合关系

如果一个操作 A 在另一个操作 B 之前发生,这种关系可以用 -> 符号表示。

比如下面的代码:

package main

import (
  "fmt"
)

var x, y int

func main() {
  go func() {
      y = 1
      fmt.Println(x)
  }()
  x = 1
  fmt.Println(y)
}

我们可以将 happens-before 定义为:

goroutine -> y = 1 -> fmt.Println(x)
main goroutine -> x = 1 -> fmt.Println(y)

比如下面的代码:

package main

import (
  "fmt"
  "os"
  "sync"
  "time"

)

var x, y int
var mu sync.Mutex

func write_y() {
  mu.Lock()
  defer mu.Unlock()
  fmt.Fprintln(os.Stdout, "call func in main goroutine get lock")
  y = 1
  fmt.Fprintf(os.Stdout, "write y = %d, and x in func call: %d\n", y, x)
}

func main() {
  fmt.Fprintf(os.Stdout, "init ==> x: %d and y: %d\n", x, y)
  go func() {
      mu.Lock()
      defer mu.Unlock()
      x = 1
      fmt.Fprintln(os.Stdout, "g1 get lock")
      fmt.Fprintf(os.Stdout, "write x = %d and y in g1: %d\n", x, y)
  }()

  write_y()
  time.Sleep(time.Second)
  fmt.Println("x in main goroutine: ", x)
}

执行上面的代码,运行结果如下:

$go run main.go

init ==> x: 0 and y: 0
call func in main goroutine get lock
write y = 1, and x in func call: 0
g1 get lock
write x = 1 and y in g1: 1
x in main goroutine:  1

首先,主 goroutine 的函数调用获得了锁然后对 y 进行了写操作,此时 x = 0,y = 1,之后,g1 获得了锁,对 x 进行了写操作,x = 1。

要求三
对于一个内存位置上面的普通的数据 x 进行读操作(r)「非同步」,W (r) 必须是一个对 r 可见的写 w,可见的写意味着以下两点都成立:

  • w happens before r

  • 保证 w 在 r 之前的情况下,在 w 之前不存在其他对 x 的写操作 w'

如果在内存上面的 x 发生了一个读写操作的数据竞争,其中至少有一个存在未同步操作,并不能保证 happens-before

如果对内存上面的变量 x 操作不存在读写数据竞争或者写写数据竞争时,对 x 的任何读只有一个可能的 W (r), 在 happens-before 顺序中,紧挨着它的前面的单一 w

更一般地说,任何 Go 程序是无数据竞争的,意味着没有程序存在读写数据竞争活着写写数据竞争,那么结果只能由一些 goroutine 执行的顺序保证串行一致性得到,也就是说,程序执行的结果是唯一可以预测的,并且满足串行一致性模型

包含数据竞争的程序在实现上的限制#

首先,可以在检测到数据竞争时,报告数据竞争并且停止程序的执行。可以通过(go build -race) ThreadSanitizer 来实现它。

如果一个内存位置 x 的读取不超过机器字长,那么这个读取操作必须要看到一个之前的写或者同时进行的写入操作。

具体来说,对于内存位置 x 来说,如果某次读取 r 没有发生在 w 之前, 并且也没有在某次 w 写入之后又存在了一个写入操作 w' 这个操作发生在 r 之前,也就是说,这个读取 r 必须能够观察到由某次之前或者同时进行的写入操作所产生的值。

以下情况不存在
case 1:
  r --> w
case 2:
  w --> w' --> r

禁止观察到非因果关心或者无中生有的写入操作

对于多字节内存的读取和写入操作,Go 语言可能无法保证与单字节内存位置操作具有相同的语义,即这个操作是否是原子的、是否具有顺序等。

为了提高性能,Go 语言的实现可能将多个字节操作看作是一组单独的机器字操作,但是顺序是未指定的。

这意味着,对于多字节数据结构的竞争可能会导致不一样的值,这些值不对应于单个写操作,这可能导致多个 goroutine 同时对同一个内存位置进行读取或者写入操作时,会出现不一致的结果。

特别是对于某些数据类型,比如接口值、map、slice、string 等,这种竞争可能导致内存数据损坏。

同步#

初始化 (init)#

所有的初始化操作都会运行在一个 goroutine 中,但是这个 goroutine 可能会创建其他同时运行的 goroutine

如果一个 package p 导入了一个 package q,q 的 init 功能会在任何 p 操作的开始之前

在 main.main 开始之前,所有的 init 函数都会完成同步

goroutine 的创建#

当使用 go 语句启动一个新的 goroutine 时,这个语句会在 goroutine 的执行之前进行同步

也就是说,go 语句会面的代码会在新的 goroutine 执行之前完成执行。这种同步机制可以确保在启动 goroutine 之前,所有必要的变量和状态已经被正确地初始化和设置,从而避免竞争条件和其他可能存在的问题

var a string

func f() {
  print(a)
}

func hello() {
  a = "hello, world"
  go f()
}

上面代码,调用 hello 函数,由于在 go f() 之前,已经将 a 赋值为 "hello, world", 所以在 f () 函数中,将会打印 "hello, world",但是因为 f () 函数是在一个新的 goroutine 中,f () 函数的执行和 hello () 函数的执行时并行的,它们之间的执行顺序是未定义的,因此,可能在 hello () 函数返回之后打印 "hello world".

goroutine 的销毁#

一个 goroutine 的退出,并不能保证与程序任何事件进行同步,比如下面的程序

var a string

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

对 a 的赋值后面没有任何同步事件,因此他不能保证被其他的 goroutine 观察到,事实上,编译器可能会删除整个 go 语句。
如果一个 goroutine 的效果必须被另一个 goroutine 观察到,请使用同步机制,比如锁或者 channel 来建立一个相对的顺序。

channel 通信#

channel 通信是 goroutine 之间同步的主要方法,一个特定通道上的每一个发送都与该通道的相应的接收相匹配,通常是在不同的 goroutine 中

👍 一个 channel 上的发送是在该 channel 相应的接收完成之前同步的 (同步并不意味着立即执行)

当一个 goroutine 向一个 channel 发送数据时,如果 channel 没有被其他 goroutine 接收数据,那么这个 goroutine 会被阻塞,直到有其他 goroutine 从这个 channel 上面接收数据为止。

在接收操作完成之前,发送操作不会完成。只有当接收操作完成后,发送操作才会成功完成,并且 channel 的数据才会被接收方获取到。

类似地,从一个 channel 中接收数据,接收数据的操作也不是立即完成的,相反,接收操作会被阻塞,直到有另外一个 goroutine 向这个 channel 中发送数据。

即:发送和接收操作是同步的,它们之间存在相互等待和同步的关系。

这种同步机制是 channel 的特性之一,确保了 channel 的安全性,避免了数据竞争条件和其他的并发问题。
比如:

var c = make(chan int, 10)
var a string

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

func main() {
  go f()
  <-c
  print(a)
}

上面的代码保证了打印 "hello, world",对 a 的写发生在向 channel c 的发送之前,而向 channel c 的发送在 channel c 接收完成之前进行同步,从 channel c 接收数据发生在打印之前

a="hello, world" ==> c<-0 ==> <-c ==> print(a)

channel 的关闭是在一个接收操作之前进行同步的,这个接收因为 channel 的关闭而返回一个零值

在 Go 语言中,关闭 channel 和从已关闭的 channel 接收数据都是同步操作。当一个 channel 关闭后, channel 上的发送操作会引起 panic,但是接收操作仍然可以继续执行,直到 channel 中的所有值都被接收完成。

当从一个已经关闭的 channel 中接收数据时,接收操作会立即返回一个零值,零值代表 channel 元素类型的默认值

关闭 channel 的操作和返回零值的接收操作是同步的。

也就是说,某个 goroutine 从 channel 中接收操作阻塞时,当 channel 被关闭时,接收操作会立即解除阻塞并返回一个零值。

上面的示例中,将 c <- 0修改为 close(c) 具有相同的行为

👍 对于无缓冲 channel,从 channel 接收数据的操作会在相应的发送操作之前同步

	  也就是说,在执行发送操作之前,接收数据的操作已经准备好了等待接收(做好了同步的准备)

unbuffered channel 是一种在 goroutine 之间进行同步和传递数据的机制,由于容量为 0,所以发送操作和接收操作都是同步的。

当一个 goroutine 向一个 unbuffered channel 中发送数据时,它会被阻塞,直到另一个 goroutine 从这个 unbuffered channel 中接收数据为止。

同样的,当一个 goroutine 从一个 unbuffered channel 接收数据时,它会被阻塞,直到另一个 goroutine 向该 channel 发送数据为止。

这种情况下,接收操作和发送操作之间是同步的

那么对于上面的代码,将其改写为 unbuffered channel 则为

var c = make(chan int)
var a string

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

func main() {
  go f()
  c <- 0
  print(a)
}

这个代码也能保证打印 "hello, world",对 a 的写发生在从 channel 接收之前,而从 channel 的接收在向 channel 发送数据之前进行了同步,而发送操作在 print 之前

a = "hello, world" ==> c <- 0 ==> <-c ==> print(a)  

但是如果 channel 是有缓冲的,「比如 `c = make (chan int, 1)」, 那么程序将不能保证打印"hello, world"

👍 对于 buffered channel,如果容量为 C,那么当一个 goroutine 第 k 次从该 channel 接收数据时,它会与第 k+C 次向该 channel 发送数据的操作进行同步。

	  换句话说,第 K 次接收操作不会完成(阻塞),直到第 K+C 次发送操作已经完成

buffered channel 的特性可以实现一个计数信号量 (counter semaphore)

🎈channel 中的元素数量对应于活动的数量,channel 的容量对应于同时使用的最大数量,向 channel 发送一个元素相当于获取信号(P 操作,等待获取信号量),从 channel 中接收一个元素会释放信号(V 操作,释放信号量)。当 channel 中的元素数量到达容量时,新的发送操作将被阻塞,直到有其他的接收操作释放了一些空间。这样可以保证同时执行的操作数量不超过 channel 的容量,从而限制并发性,这是一种常见的并发编程模式。

这个程序对于工作列表中的每个条目都启动一个 goroutine,但是这些 goroutine 会使用一个 名为 limit 的 channel 进行协调,确保每次最多只有 3 个 goroutine 同时运行。

var limit = make(chan int, 3)

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

#

sync package 实现了两种数据类型的锁,sync.Mutexsync.RWMutex

对于任何 sync.Mutexsunc.RWMutex 的变量 l 和 n < m,对 n 的 l.Unlock()的调用会在对 m 的 l.Lock()之前被同步

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, wrold", 第一个对 l.Unlock() 的调用(在函数 f 中)在第二个对 l.Lock() 的调用(在 main 函数中)返回之前进行同步,并在打印操作之前执行

对于任意对 sync.RWMutex 变量 l 的调用 l.RLock, 有一个 n, 使得对 l.Unlock 的第 n 次调用在 l.RLock 的返回之前被同步,并且对 l.RUnlock 的匹配调用在对 l.Lockn+1 次调用返回之前被同步

l.TryLock (或者 l.TryRLock ) 的成功调用等同于对 l.Lock (或者 l.RLock)的调用,如果尝试获取锁失败,则没有任何同步效果,返回 false,在内存模型方面,l.TryLock (或者 l.TryRLock) 具有轻量级的特性,即它可以在锁未被锁定的时候返回 false, 这可以提高程序的并发性能。

Once#

sync包通过使用 Once 类型,为存在多个 goroutine 的情况下的初始化提供了一种安全的机制。多个线程可以为一个特定的 f 执行 once.Do(f),但只有一个线程会运行 f (), 其它的调用会阻塞,直到 f () 被返回

在调用 sync.OnceDo 方法时,对于同一个 sync.Once 实例和同一个函数 f,任何一次对 Do (f) 方法的调用都会等待之前的调用完成后,再继续执行

var a string
var once sync.Once

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

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

func kprint(k int) {
  for i:=0; i < k; i++ {
    go doprint()
  }
}

在调用 kprint 正好会调用 setup 一次,在调用 print 之前,setup 函数将会完成。结果是,"hello, world" 会被打印 k 次

原子值#

sync/atomic 包中的 API 统称为 "原子操作",可以用来同步不同的 goroutine 的执行。如果一个原子操作 A 的效果被原子操作 B 观察到,那么 A 就在 B 之前被同步了。

这个定义与 C++ 的顺序一致的原子和 Java 的 volatile 变量具有相同的语义。

Finalizers#

runtime 包提供了一个 SetFinalizer 函数,它增加了一个 finalizer,当某个对象不再能被程序触发时就会被调用。对 SetFinalizer(x, f) 的调用是在调用 f(x)终止化 之前同步进行的。

在 Go 语言的内存模型中,Finalizers 是指一种机制,用于在对象被垃圾回收时执行某些清理操作。

具体来说,在 Go 语言中,当一个对象变成不可达时,它会被垃圾回收器回收。如果这个对象定义了 Finalizer 函数,那么在垃圾回收器回收这个对象之前,它会调用这个 Finalizer 函数对这个对象进行清理操作。

Finalizer 函数可以被定义为一个方法,它接收一个对象作为参数,并且不返回任何值。这个函数通常用于释放一些资源,例如关闭文件描述符、释放网络连接等。

需要注意的是,Finalizer 函数执行的时间是不确定的,因为它依赖于垃圾回收器的工作机制。另外,Finalizer 函数可能会影响垃圾回收器的性能,因为它需要在垃圾回收器回收对象之前执行。

因此,在使用 Finalizer 函数时需要谨慎,尽量避免对性能造成影响,并确保 Finalizer 函数不会引起其他的内存管理问题,例如内存泄漏等。

其他机制#

sync 包提供了额外的同步抽象,包括条件变量(condition variables)、无锁的 map(lock-free maps)、分配池(allocation-pools)和等待组(wait groups)。每个包的文档都说明了它对同步的保证。

不正确的同步#

有数据竞争的程序是不正确的,会表现出非顺序一致性。尤其要注意的是,一个读操作 r 可能会观察到与 r 同时执行的任何写操作 w 所写的值。即使这样,也不能保证读操作 r 会观察到在 w 之前发生的写操作。

竞态条件的出现会导致程序的执行结果不可预测。如果在并发执行过程中存在读写操作的竞争条件,那么读操作可能会观察到任何并发执行的写操作的值,而这些写操作可能发生在读操作之前或者之后。因此要避免静态条件的出现,确保程序的正确性和顺序一致性

var a, b int

func f() {
  a = 1
  b = 2
}

func g() {
  print(b)
  print(a)
}

func main() {
  go f()
  g()
}

上面代码的执行结果是不确定的,可能打印 "00" 也可能打印 "01"

双重检查是为了避免同步化的开销,比如 kprint程序可能错误地写成:

var a string
var done bool

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

func doprint() {
  if !done {
    once.Do(setup)
  }
  print(a)
}

func kprint(k int) {
  for i := 0; i < k; i++ {
    go doprint()
  }
}

上面的代码不能保证,在 doprint 中,同时观察到了对 done 和变量 a 的写操作,但是这并不能保证在 doprint 函数中观察到对 done 的写操作就意味着观察到了对 a 的写操作,因此这个版本可能会错误打印出空字符串,而不是 "hello, world"

一般来说,编译器和 cpu 都会对程序进行优化,从而进行偏序操作,而且在多线程中,线程的执行顺序是不确定的,一个指令也有可能被拆分为多个指令,因此在编程语言层面看似是一个操作,很有可能编译器或者 CPU 将指令拆分,因此,如果在编写并发程序时,尽可能的使用适当的同步机制保证程序的正确性

千万别耍小聪明,将代码改写为这样

var a string
var done bool

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

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

如前所述,在 main 中,无法保证观察到对 done 的写入意味着观察到对 a 的写入,所以这个程序也可以打印一个空字符串。更糟的是,由于两个线程之间没有同步事件,所以无法保证 done 的写入会被 main 观察到。 main 中的循环也不能保证完成。

还有一个变体:

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 并退出了它的循环,也不能保证它能观察到 g.msg 的初始化值。

👍 在所有这些例子中,解决方案是相同的:使用显式同步

不正确的编译#

Go 内存模型对编译器优化的限制和对 Go 程序的限制一样多。一些在单线程程序中有效的编译器优化在所有 Go 程序中都是无效的。特别是,编译器不能引入原程序中不存在的写,不能允许一次读来观察多个值,也不能允许一次写来写多个值。

也就是说,如果存在多线程(多个 goroutine), 编译器将不会像优化单线程那样对程序进行优化。

这个规则的目的是确保多个 goroutine 之间共享变量时的正确性和可预测性。如果编译器允许优化,可能会导致共享变量的值不一致,因为读写操作的执行顺序是不确定的,因此,编译器必须遵守规则

下面所有的例子都假定 *p*q 指向了多个 goroutine 可以访问的内存位置。

🎈 在编写无数据竞争的程序时,为了避免数据竞争, 不能将写入操作移出条件语句之外。

比如,编译器不能对这个程序中的条件进行反转

*p = 1
if cond {
  *p = 2
}

也就是说,编译器不能将程序进行如下的改写

*p = 2
if !cond {
  *p = 1
}

如果条件不成立,并且存在另外一个 goroutine 读取 *p,在原始的程序中,另一个 goroutine 只能观测到先前的 *p 值是 1,但是如果进行了重写,重写后的程序中,另一个 goroutine 可以观察到 2,这在前面的程序是不可能的

🎈 不引入数据竞赛也意味着不假定循环会终止。

例如,一般来说,编译器不能把对 *p 或 *q 的访问移到这个程序中的循环前面:

n := 0
for e := list; e != nil; e = e.next {
  n++
}
i := *p
*q = 1

如果 list 指向一个循环链表,那么原始程序永远不会访问 *p*q,但是重写后的程序会访问它们。(如果编译器可以证明 *p 不会引发 panic,那么将 *p 移动到前面是安全的;将 *q 移动到前面也需要编译器证明没有其他 goroutine 会访问 *q。)

🎈确保不引入数据竞争也意味着不能假设调用的函数总是返回或不包含同步操作。

例如,在下面这个程序中,编译器不能在函数调用之前移动对 *p*q 的访问(至少不能在没有直接了解 f 函数精确行为的情况下这样做):

f()
i := *p
*q = 1

如果函数调用从未返回,那么原始程序再次永远不会访问 *p*q,但是重写后的程序会访问它们。而如果函数调用包含同步操作,那么原始程序可以建立在访问 *p*q 之前的 happens-before 关系,但是重写后的程序则不会。

🎈 不允许单个读操作观察到多个值意味着不能从共享内存中重新加载本地变量。

例如,在下面这个程序中,编译器不能丢弃 i 并从 *p 中第二次重新加载它:

i := *p
if i < 0 || i >= len(funcs) {
  panic("invalid function index")
}

... complex code (复杂的代码)...

// compiler must NOT reload i = *p here
funcs[i]()

如果复杂的代码需要许多寄存器,对于单线程程序的编译器可以在没有保存副本的情况下丢弃 i,然后在 funcs[i]() 前刚好重新加载 i = *p。但是 Go 编译器不能这样做,因为 *p 的值可能已经发生了变化。(相反,编译器可以将 i 溢出到堆栈中。)

🎈 不允许单个写入操作写入多个值也意味着不能在写入之前使用将要写入本地变量的内存作为临时存储。

例如,在下面这个程序中,编译器不能将 *p 用作临时存储:

*p = i + *p/2

也就是说,它不能把程序改写成这个程序:

*p /= 2
*p += i

如果 i*p 起始值相等且为 2,那么原始代码会执行 *p = 3,因此竞争的线程只能从 *p 中读取 2 或 3。而重写后的代码先执行 *p = 1,然后再执行 *p = 3,这样竞争线程还可以读取到 1。

🎈 请注意,所有这些优化在 C/C++ 编译器中都是允许的:与 C/C++ 编译器共享后端的 Go 编译器必须小心禁用对 Go 无效的优化。

当编译器可以证明数据竞争不会影响目标平台上的正确执行时,禁止引入数据竞争的规则不再适用。例如,在几乎所有的 CPU 上,可以进行如下重写:

n := 0
for i := 0; i < m; i++ {
  n += *shared
}

重写为:

n := 0
local := *shared
for i := 0; i < m; i++ {
  n += local
}

前提是可以证明访问 *shared 不会导致故障,因为潜在的额外读取不会影响任何现有的并发读取或写入。另一方面,在源到源的转换器中,这种重写是无效的。

总结#

编写无数据竞争程序的 Go 程序员可以依赖这些程序的顺序一致性执行,就像几乎所有其他现代编程语言一样。

在处理带有数据竞争的程序时,程序员和编译器都应该记住这个建议:不要耍小聪明

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。