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, world", 第一次對 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 程序員可以依賴這些程序的順序一致性執行,就像幾乎所有其他現代編程語言一樣。

在處理帶有數據競爭的程序時,程序員和編譯器都應該記住這個建議:不要耍小聰明

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。