1. 程式人生 > 其它 >【譯】go記憶體模型

【譯】go記憶體模型

go記憶體模型

原文: https://go.dev/ref/mem

源文:https://github.com/cool-firer/docs/blob/main/go/go%E5%86%85%E5%AD%98%E6%A8%A1%E5%9E%8B%E7%BF%BB%E8%AF%91.md

介紹

Go記憶體模型制定了一套規則:針對同一個變數,一個goroutine的讀操作如何才能保證(guarantee)觀察到(observe)另一個goroutine的寫操作。

翻譯翻譯,什麼叫觀察到?

有如下程式碼

檢視程式碼
package main

import (
	"fmt"
	"runtime"
	"sync"
)

var v string
var done bool
var wg sync.WaitGroup

func write() {
	v = "hello world"
	done = true

	if done {
		fmt.Println("wrote,", len(v))
	}
	wg.Done()
}

func read() {
	for !done {
		runtime.Gosched()
	}
	fmt.Println("read,", v)
	wg.Done()
}

func main() {
	wg.Add(2)

	go write()
	go read()

	wg.Wait()
}

如果編譯器沒有重排,輸出結果是:

// write 先行於 read執行
wrote, 11
read, hello world

read讀取到了write後的值,這就是說read觀察到了其他goroutine對變數v的寫,符合我們的預期。

然蛾,假設編譯器在後臺對write進行重排,如下:

func write() {
	done = true
	v = "hello world"

	if done {
		fmt.Println("wrote,", len(v))
	}
	wg.Done()
}

done與v的順序重排了,此時可能的輸出結果有:

// 預期情況:write 先行於 read執行
wrote, 11
read, hello world

// 非預期情況:write賦值done後放出cpu -> read排程執行完 -> write排程執行剩下的write v
read,
wrote, 11

read沒有讀取到write後的值,這就是說read沒有觀察到其他goroutine對變數v的寫,不符合我們的預期。

那要怎麼搞?自己寫的程式碼,編譯器還來插一手,插一手就算了,還插的不對?有什麼好辦法,引出建議。

建議

如果修改資料的同時,有多個goroutine訪問這個資料,那必須要序列化訪問。

要實現序列化訪問,用channel或者sync、async/atomic包裡的同步原語。

如果你必須得讀文件剩下的內容才能理解你自己寫的程式碼,那你就太水了。

做人不要這麼水。

 

Happens Before

在單個gorountine內部,讀和寫必須以程式定義的順序來執行,編譯器和處理器只能在不影響單個gorountine的執行行為的情況下,才可以進行重排。因為存在重排,一個gorountine觀察到的執行順序可能與另一個gorountine觀察到的不一樣。比如,goroutine執行a = 1; b = 2;

,在另一個gotoutine裡觀察到的可能是b = 2; a = 1;

這段是對介紹部分裡的例子做進一步解釋,編譯器不能亂重排,不能影響單個goroutine的執行結果。

舉個例子:

func write() {
	v = "hello world"

  if done {
    v = "hello reorder"
  }
	done = true

	if done {
		fmt.Println("wrote,", len(v))
	}
	wg.Done()
}

這種情況就不能重排v和done,一旦重排,v的值就變了,就會影響這個goroutine的執行結果。

為指定讀與寫的順序要求,我們定義了happens before -- 記憶體操作上的區域性順序。如果事件e1發生在事件e2之前(happens before),那們也可以說e2發生在e1之後(happens after)。如果e1既不發生在e2之前,也不發生在e2之後,那我們就說,e1e2同時發生。

e1發生在e2之前,不是很自然地可以說e2發生在e1之後嘛,為什麼要特地提一嘴說e2發生在e1之後呢?

我的理解是,為了擴充套件定義,並定義清楚。作為官方文件,如果只說happens before,不說別的。作為讀者,可能會自行腦補擴充套件happens after、happens afterwards、happens between等名詞, 這將不利於討論。

在單個goroutine內,happens before順序就是程式碼展現的順序。

要時刻意識到,happens before是對讀、寫而言。

在單個goroutine內,讀、寫同一個變數,你程式碼裡怎麼寫的,實際就是怎麼執行的。所以才會有這麼一句話。

 

對變數v,只有同時滿足以下兩個條件,讀 操作r才被允許觀察到寫操作w

  1. r不發生在w之前.
  2. 不存在其他的的w'發生在w之後、不在r之前.

關鍵詞:允許(allowed)。

允許,相當於拿到一張抽獎券,至於能不能中獎,後面再說。反正沒有這張券,是必然不能中獎的。

1. r不發生在w之前.

如果1不滿足,即r發生在w之前,那r就必然觀察不到w的結果,必然不能中獎。所以最低的規定得是r不發生在w之前,這時就有兩種情況:

a. r發生在w之後;

b. r、w同時發生;

對於a好理解,b的同時發生要怎麼理解呢?

對於單條指令來說,是不能同時操作某個記憶體變數的(某乎上的講解),這裡的同時是併發的意思,所以這裡的r、w不能指代單條指令,而應該把r、w看成一個指令集合,也就是說,r、w是一個巨集觀的行為,裡在包含了多個指令。這樣,r、w就可以說是同時發生,還是會出現r的某一個指令會先於w的指令。比如說:

r包含的指令集,每一條都是原子指令:{
  read x1;
  read x2;
  read v;
}

w包含的指令集,每一條都是原子指令: {
  write x1;
  write x2;
  write v;
}

既然如此,為什麼不直接把1定義為『r發生在w之後』呢?我的理解是,如果直接定義,就相當於直接中獎了,不符合允許這一詞的特性,所以才定義的比較軟。

1的定義有一個漏洞,r是發生在w之後了,但在在w發生後,以可能會有w1、w2、w3等別的寫操作。我要的是r只針對特定的w,不能被其他wx覆蓋。所以增加了2。

原文是: There is no other write w' to v that happens after w but before r. 有人翻譯為:沒有其他寫操作發生在w之後和r之前。 如果這樣翻,那原文為什麼不是:There is no other write w' to v that happens after w and before r. 為什麼要用but?這裡或許給出了答案   簡化一下就是:There is no other write that is not before r. 不存在別的寫操作,什麼樣的寫操作?不在r之前的寫操作。 沒有其他的寫,不在r之前。 有其他的寫,在r之前。 所以2的意思是:其他的寫操作發生在w之前 or w同時,並且發生在r之前。 同樣的,對於同時發生的情況,指令集的情況,其他的wx可能會覆蓋w的write。  

對變數v,只有同時滿足以下兩個條件,讀操作r才被保證觀察到特定的寫操作w

1. w發生在r之前;

2. 其他的w'發生在w之前,或者r之後;

關鍵詞:保證(guarantee)

保證就是直接抽到獎了,好理解。

這對條件要比第一對條件強,因為它要求在wr之間沒有其他的寫操作。

在單個goroutine內,沒有同時、併發的情況,所以兩對定義是等效的:結果都是讀觀察到了最近的一次寫。當多個goroutine訪問同一個變數v時,必須要使用一些同步手段來建立happens before條件,才能確保讀觀察到寫。

在記憶體模型中,對變數v的進行零值初始化被視為一個寫。

讀寫一個比單機器字大的值,會以非特定的順序操作多機器字。

暫時沒想到這個跟文件有什麼關係

同步手段

初始化

初始化操作是在單個goroutine內執行,但這個goroutine可能會建立其他的goroutine,導致併發執行。

如果p包 import q包,q包的init函式 happens before 包的起始程式碼。

main.main函式 happens after 所有的init函式完成。

這其實規定了匯入包時,包變數、包init的執行順序。借用beego上的一張圖

 

建立goroutine

go語句開啟新的goroutine happens before 新開的goroutine開始執行。

比如下面程式碼:

var a string

func f() {
	print(a)
}

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

呼叫hello後,在將來某個時間點(也許是在hello退出後)會打印出"hello, world"

我的理解是,如果程式碼裡存在建立新gorontine的,且存在寫入操作,那就不能重排。

銷燬goroutine

goroutine的退出不能保證 happens before 其他的任何事件。比如下面的程式碼:

var a string

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

寫入a的操作沒有任何同步機制,不能保證被其他goroutine觀察到。事實上,一個進擊的編譯器可能會刪掉整個go語句。

如果goroutine的影響必須能被其他gorountine觀察到,使用同步機制建立順序,比如鎖lockchannel通訊。

這段有點廢話,我想這段的用意是在再一次強調同步機制吧。不用刻意去說明銷燬goroutine,當它是正常執行就行。

channel通訊

channel通訊是goroutine之間最主要的同步方式。在一個channel上,每一個傳送都有與之對應的接收,發與接通常是在不同的goroutine之間。

channel的傳送 happens before 接收完成。

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". 對變數a的寫 happens before 傳送channel,而傳送channel happens before 接收端完成,而接收完成又 happens before 列印變數a

這意味著,如果goroutine記憶體在傳送channel,那就不會重排。

關閉channel happens before 接收完成,並會收到零值。

在上面那個例子,把c <- 0替換成close(c),也能保證打印出"hello, world".

作為上面的補充,close(channel)相應於send了個零值。

從一個無緩衝的channel上接收 happens before 傳送完成

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的寫 happens before 接收channel,而接收channel happens before 傳送完成,而傳送完成又 happens before列印變數a

這說明:

  1. 傳送端不重排;因為在講上一個情況時,沒有特意說明channel是緩衝還是不緩衝;

  2. channel無緩衝,接收端的goroutine不重排,且接收happens before 傳送;

如果上面的例子中,如果channel帶緩衝,c = make(chan int 1),那就不能保證列印"hello, workd",可能會列印空字串、崩潰或者其他。

package main

import (
	"fmt"
)

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

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

func main() {
	go f()
	c <- 0
	fmt.Println(a)
}

改成帶緩衝的,首先,channel是帶緩衝,不適合上面的12總結。

要分析的話,得用到下一條規則,總結3:

channel帶緩衝,傳送端與接收端不在同一位置,傳送 happens before 接收;

這就有可能產生非預期情況,列印空字串。

容量是C的channel上,第k次的接收 happens before 第 k+C上的傳送完成。

這條規則是上一條緩衝channel的一般化表述。帶緩衝的channel使用計數訊號量模型:channel裡的資料與活躍數量對應,channel的容量與最大同步數量對應,傳送時需要獲取訊號量,接收時釋放訊號量。這是限制併發的常規手段。

下面的例子遍歷每個worker建立一個goroutine,但用channel保證最多隻有三條gorountine併發執行worker。

var limit = make(chan int, 3)

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

接收位置是K,K+C就是發了一圈回到原來K的位置。

如果此時在位置K上,有goroutine阻塞於接收,那麼接收 happens before 傳送完成。

所以,前面對於channel的分情況總結,還需要完善,完整的總結應該是這樣:

  1. 傳送端不重排;

  2. channel無緩衝,接收端的goroutine不重排,接收 happens before 傳送;(這點沒改變)

  3. channel帶緩衝,傳送端與接收端在同一位置,接收 happens before 傳送;(補充)

  4. channel帶緩衝,傳送端與接收端不在同一位置,接收與傳送無必然先後;(補充, 由3引申而來)

對於3,其實可以歸到2裡去,同一個位置就是無緩衝的情況,接收 happens before 傳送。

sync包實現了兩種鎖:sync.Mutexsync.RWMutex

變數lsync.Mutexsync.RWMutex,且n < m,第n次的l.Unlock() happens beforem次的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函式裡) happens before 第二次l.Lock()(main函式裡),l.Lock() happens before 列印。

這說明:

  1. Unlock不重排;

  2. Lock與Unlock分開計數;

  1. call n of xxx翻譯為:第n次xxx;

變數lsync.RWMutex,對於l.RLock,存在一個n,使得l.RLock happens after l.Unlockn次呼叫,與之對應的l.RUnlock happens before l.Lockn+1次呼叫。

這,不用翻譯,應該都能懂。

Once

sync包提供了Once型別,用於多goroutine情況下的初始化。多個執行緒能呼叫once.Do(f),但只有一個會執行f()函式,其他的呼叫會阻塞,直到f()呼叫完。

once.Do(f)單個呼叫f() happens before 任何once.Do(f)

var a string
var once sync.Once

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

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

func twoprint() {
	go doprint()
	go doprint()
}

呼叫twoprint,只會執行一個setup函式。setup函式會比print呼叫。結果是"hello, world"會列印兩次。

不正確的同步

讀與寫併發執行時,讀操作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()
}

g可能打印出2、0

這個事實讓一些常用語句失效了。顛覆了我們對程式碼的慣用認識。

雙重檢查鎖是避免同步開銷的一種嘗試。比如,twoprint可能會錯誤的寫成這樣:

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寫入。上面這個程式碼可能列印空字串。

另一個不正確的慣例是忙等待一個值,比如:

var a string
var done bool

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

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

像前面的例子一樣,在main中,不能保證觀察到了done寫入就一樣觀察到了a的寫入,所以程式可能列印空字串。更糟糕的是,不能保證main能觀察到done寫入,因為在兩個執行緒之間沒有同步事件,main中的迴圈不能保證可以結束。

不太理解為什麼說main中的迴圈不能保證結束,唯一的解釋是,setup被進擊的編譯器刪除了。

還有一個變體,如下:

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的寫入。

這就是發生指令重排了。

t := new(T)
g = t
t.msg = "hello, world"

對於所有的例子,解決辦法都是一樣的:直接用顯式同步。

ps: 部落格園的編輯器太難用了,在考慮把文轉到某乎上。