Golang?內(nèi)存模型The?Go?Memory?Model
1. 簡(jiǎn)介(Introduction)
本文翻譯了原文并加入了自己的理解。
主要介紹多個(gè) Go協(xié)程之間對(duì)同一個(gè)變量并發(fā)讀寫時(shí)需要注意的同步措施和執(zhí)行順序問題。并列出幾個(gè)常見錯(cuò)誤。
Go 內(nèi)存模型涉及到多個(gè) Go協(xié)程之間對(duì)同一個(gè)變量的讀寫。
假如有一個(gè)變量,其中一個(gè) Go協(xié)程(a) 寫這個(gè)變量,另一個(gè) Go協(xié)程(b) 讀這個(gè)變量;Go 內(nèi)存模型定義了什么情況下 Go協(xié)程(b) 能夠確保讀取到由 Go協(xié)程(a) 寫入的值。
2. 建議(Advice)
- 如果多協(xié)程并發(fā)修改數(shù)據(jù),必須保證各個(gè)步驟串行執(zhí)行(序列化訪問)。
- 為了串行執(zhí)行,可以使用
channel或其他同步原語( 如sync和sync/atomic兩個(gè)包里的那些)來保護(hù)被共享的數(shù)據(jù)。
3. 發(fā)生在…之前(Happens Before)
除了重排序需要理解,其余概念其實(shí)沒那么重要,看后面的例子就懂了。
3.1 重排序
當(dāng)只有一個(gè) Go協(xié)程時(shí),對(duì)同一個(gè)變量的讀寫必然是按照代碼編寫的順序來執(zhí)行的。對(duì)于多個(gè)變量的讀寫,如果重新排序不影響代碼邏輯的正常執(zhí)行,編譯器和處理器可能會(huì)對(duì)多個(gè)變量的讀寫過程重新排序。
比如對(duì)于 a = 1; b = 2 這兩個(gè)語句,在同一個(gè) Go協(xié)程里先執(zhí)行 哪個(gè)其實(shí)是沒有區(qū)別的,只要最后執(zhí)行結(jié)果正確就行。
a := 1//1 b := 2//2 c := a + b //3
但是,因?yàn)橹匦屡帕袌?zhí)行順序的情況的存在,會(huì)導(dǎo)致**某個(gè) Go協(xié)程所觀察到的執(zhí)行順序可能與另一個(gè) Go協(xié)程觀察到的執(zhí)行順序不一樣。**可能另一個(gè) Go協(xié)程 觀察到的事實(shí)是 b 的值先被更新,而 a 的值被后更新。
3.2 happens-before
為了表征讀寫需求,我們可以定義 happens-before,用來表示 Go 語言中某一小段內(nèi)存命令的執(zhí)行順序。
- 如果事件 e1 發(fā)生在事件 e2 之前,此時(shí)我們就認(rèn)為 e2 發(fā)生在 e1 之后。
- 如果事件 e1 既不發(fā)生在事件 e2 之前,也不發(fā)生在 e2 之后,此時(shí)我們就認(rèn)為 e1 和 e2 同時(shí)發(fā)生(并發(fā))(并發(fā) ≠ 并行)。
3.3 規(guī)則
在只有一個(gè) Go協(xié)程的內(nèi)部,happens-before的順序就是代碼顯式定義的順序。當(dāng) Go協(xié)程 不僅僅局限在一個(gè)的時(shí)候,存在下面兩個(gè)規(guī)則:
- 如果存在一個(gè)變量
v,下面的兩個(gè)條件都滿足,則讀操作r允許觀察到(可能觀察到,也可能觀察不到)寫操作w寫入的值。
r不在w之前發(fā)生;- 不存在其他的
w’在w之后發(fā)生,也不存在w’在r之前發(fā)生。
- 為了保證讀操作
r讀取到的是寫操作w寫入的值,需要確保w是唯一允許被r觀察到的寫操作。如果下面的兩個(gè)條件都滿足,則r保證能夠觀察到w寫入的值:
w發(fā)生在r之前;- 其他對(duì)共享變量
v的寫操作要么發(fā)生在w之前,要么發(fā)生在r之后。
規(guī)則二的條件比規(guī)則一的條件更為嚴(yán)格,它要求沒有其他的寫操作和 w、r 并發(fā)地發(fā)生。
在一個(gè) Go協(xié)程 里是不存在并發(fā)的,因此規(guī)則一和規(guī)則二是等效的:讀操作 r 可以觀察到最近一次寫操作 w 寫入的值。
但是,當(dāng)多個(gè)協(xié)程訪問一個(gè)共享變量時(shí),就必須使用同步事件來構(gòu)建 happens-before 的條件,從而保證讀操作觀察到的一定是想要的寫操作。
在內(nèi)存模型中,變量 v 的零值初始化操作等同于一個(gè)寫操作。
如果變量的值大于單機(jī)器字(CPU 從內(nèi)存單次讀取的字節(jié)數(shù)),那么 CPU 在讀和寫這個(gè)變量的時(shí)候是以一種不可預(yù)知順序的多次執(zhí)行單機(jī)器字的操作,這也是 sync/atomic 包存在的價(jià)值。
4. 同步(Synchronization)
4.1 初始化(Initialization)
程序的初始化是在一個(gè)單獨(dú)的 Go協(xié)程 中進(jìn)行的,但是這個(gè)協(xié)程可以創(chuàng)建其他的 Go協(xié)程 并且二者并發(fā)執(zhí)行。
每個(gè)包都允許有一個(gè) init 函數(shù),當(dāng)這個(gè)包被導(dǎo)入時(shí),會(huì)執(zhí)行該包的這個(gè) init 函數(shù),做一些初始化任務(wù)。
- 如果一個(gè)包
p導(dǎo)入了包q, 那么q的init函數(shù)的執(zhí)行發(fā)生在p的所有init函數(shù)的執(zhí)行之前。(即包的引用鏈) - 函數(shù)
main.main的執(zhí)行發(fā)生在所有的init函數(shù)執(zhí)行完成之后。
4.2 Go協(xié)程的創(chuàng)建(Goroutine creation)
通過 go 語句啟動(dòng)新的 Go協(xié)程這個(gè)動(dòng)作,發(fā)生在新的 Go協(xié)程的執(zhí)行之前。比如下面的例子:
var a string
func f() {
print(a)
}
func hello() {
a = "hello, world"
go f()
}
調(diào)用函數(shù) hello 會(huì)在調(diào)用后的某個(gè)時(shí)間點(diǎn)打印 “hello, world” ,這個(gè)時(shí)間點(diǎn)可能在 hello 函數(shù)返回之前,也可能在 hello 函數(shù)返回之后。
4.3 Go協(xié)程的銷毀(Goroutine destruction)
Go協(xié)程的退出無法確保發(fā)生在程序的某個(gè)事件之前。比如下面的例子:
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}
其中 a 的賦值語句沒有任何的同步措施,因此無法保證被其他任意的 Go 協(xié)程(例如 hello 函數(shù)本身)觀察到這個(gè)賦值事件的存在。
一些激進(jìn)的編譯器可能會(huì)在編譯階段刪除上面代碼中的整個(gè) go 語句。
如果某個(gè) Go協(xié)程 里發(fā)生的事件必須要被另一個(gè) Go協(xié)程 觀察到,需要使用同步機(jī)制進(jìn)行保證,比如使用鎖或者信道(channel)通信來構(gòu)建一個(gè)相對(duì)的事件發(fā)生順序。
4.4 信道通信(Channel communication)
這部分介紹通過 channel 實(shí)現(xiàn)并發(fā)順序控制。
有緩存channel
信道通信是多個(gè) Go協(xié)程 間事件同步的主要方式。在某個(gè)特定的信道上發(fā)送一個(gè)數(shù)據(jù),則對(duì)應(yīng)地可以在這個(gè)信道上接收一個(gè)數(shù)據(jù),一般情況下是在不同的 Go協(xié)程 間發(fā)送與接收。
- 規(guī)則一:在某個(gè)信道上發(fā)送數(shù)據(jù)的事件發(fā)生在相應(yīng)的接收事件之前。
即一定是先發(fā)送數(shù)據(jù),才能接收到數(shù)據(jù)這個(gè)順序。
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` 的打印。因?yàn)樾诺赖膶懭胧录?`c <- 0` 發(fā)生在讀取事件 `<-c` 之前,而 `<-c` 發(fā)生在 `print(a)`之前。信道未被讀取時(shí)協(xié)程會(huì)阻塞。
- 規(guī)則二:信道的關(guān)閉事件發(fā)生在從信道接收到零值(由信道關(guān)閉觸發(fā))之前。
即一定是先關(guān)閉 channel,才能接收到零值。
在前面的例子中,可以使用close(c)來替代c <- 0語句來保證同樣的效果。
無緩存 channel
- 規(guī)則三:對(duì)于沒有緩存的信道,數(shù)據(jù)的接收事件發(fā)生在數(shù)據(jù)發(fā)送完成之前。
即信道容量為0時(shí),只有發(fā)送的信息被讀取了才算發(fā)送成功,否則阻塞。
比如下面的代碼(類似上面給出的代碼,但是使用了沒有緩存的信道,且發(fā)送和接收的語句交換了一下):
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`。因?yàn)樾诺赖膶懭胧录?`c <- 0` 發(fā)生在讀取事件 `<-c` 之前,而 `<-c` 發(fā)生在寫入事件 `c <- 0` 完成之前,同時(shí)寫入事件 `c <- 0` 的完成發(fā)生在 `print` 之前。
上面的代碼,如果信道是帶緩存的(比如 `c = make(chan int, 1)`),程序?qū)⒉荒鼙WC會(huì)打印出 `hello, world`,它可能會(huì)打印出空字符串,也可能崩潰退出,或者表現(xiàn)出一些其他的癥狀。
規(guī)則抽象
- 規(guī)則四:對(duì)于容量為 C 的信道,接收第 k 個(gè)元素的事件發(fā)生在第 k+C 個(gè)元素的發(fā)送之前。
規(guī)則四是規(guī)則三在帶緩存的信道上的推廣。 - 它使得帶緩存的信道可以模擬出計(jì)數(shù)信號(hào)量:**信道中元素的個(gè)數(shù)表示活躍數(shù),信道的容量表示最大的可并發(fā)數(shù);發(fā)送一個(gè)元素意味著獲取一個(gè)信號(hào)量,接收一個(gè)元素意味著釋放這個(gè)信號(hào)量。**這是一種常見的限制并發(fā)的用法。
- 下面的代碼給工作列表中的每個(gè)入口都開啟一個(gè) Go協(xié)程,但是通過配合一個(gè)固定長(zhǎng)度的信道保證了同時(shí)最多有 3 個(gè)運(yùn)行的工作(最多 3 個(gè)并發(fā))。
var limit = make(chan int, 3)
func main() {
for _, w := range work {
go func(w func()) {
limit <- 1 // channel里達(dá)到3個(gè)即阻塞
w()
<-limit // 取出后channel里小于3個(gè)即可繼續(xù)
}(w)
}
select{}
}
4. 鎖
包 sync 實(shí)現(xiàn)了兩類鎖數(shù)據(jù)類型,分別是 sync.Mutex 和 sync.RWMutex,即互斥鎖和讀寫鎖。
- 規(guī)則一:對(duì)于類型為
sync.Mutex和sync.RWMutex的變量l,如果存在 n 和 m 且滿足n < m,則l.Unlock()的第 n 次調(diào)用返回發(fā)生在l.Lock()的第 m 次調(diào)用返回之前。
即先解開上一次鎖才能上這一次鎖。
比如下面的代碼:
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 次調(diào)用返回(在函數(shù) f 內(nèi)部)發(fā)生在 `l.Lock()` 的第 2 次調(diào)用返回之前,后者發(fā)生在 `print` 之前。
- 規(guī)則二:存在類型
sync.RWMutex的變量l,如果l.RLock的調(diào)用返回發(fā)生在l.Unlock的第 n 次調(diào)用返回之后,那么其對(duì)應(yīng)的l.RUnlock發(fā)生在l.Lock的第 n+1 次調(diào)用返回之前。
即讀鎖可以上多次,但是只要沒有全解開就不能上寫鎖,寫鎖只能上一個(gè),不解開讀寫鎖都不能上。
5. 單次運(yùn)行
包 sync 還提供了 Once 類型用來保證多協(xié)程的初始化的安全。
多個(gè) Go協(xié)程 可以并發(fā)執(zhí)行 once.Do(f) 來執(zhí)行函數(shù) f, 且只會(huì)有一個(gè) Go協(xié)程會(huì)運(yùn)行 f(),其他的 Go 協(xié)程會(huì)阻塞到 f() 運(yùn)行結(jié)束(不再執(zhí)行 f,但能得到運(yùn)行結(jié)果)
- 規(guī)則一:函數(shù)
f()在once.Do(f)的單次調(diào)用返回發(fā)生在其他所有的once.Do(f)調(diào)用返回之前。
比如下面的代碼:
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
- 上面代碼使用
wg sync.WaitGroup等待兩個(gè)goroutine運(yùn)行完畢,由于setup over只輸出一次,所以setup方法只運(yùn)行了一次 - 函數(shù)
setup函數(shù)的執(zhí)行返回發(fā)生在所有的print調(diào)用之前,同時(shí)會(huì)打印出兩次hello, world,即當(dāng)一個(gè)goroutine在執(zhí)行setup方法時(shí)候,另外一個(gè)在阻塞。
6. 不正確的同步方式
6.1 案例一
對(duì)某個(gè)變量的讀操作 r 一定概率可以觀察到對(duì)同一個(gè)變量的并發(fā)寫操作 w,但是即使這件事情發(fā)生了,也并不意味著發(fā)生在 r 之后的其他讀操作可以觀察到發(fā)生在 w 之前的其他寫操作。(這里的先后指的是代碼里面聲明的操作的先后順序,而不是實(shí)際執(zhí)行時(shí)候的)
比如下面的代碼:
var a, b int
func f() {
a = 1
b = 2
}
func g() {
print(b)
print(a)
}
func main() {
go f()
g()
}
上面的代碼里函數(shù) g 可能會(huì)先打印 2(b的值),然后打印 0(a的值)。可能大家會(huì)認(rèn)為既然 b 的值已經(jīng)被賦值為 2 了,那么 a 的值肯定被賦值為 1 了,但事實(shí)是兩個(gè)事件的先后在這里是沒有辦法確定的,因?yàn)榫幾g器會(huì)改變執(zhí)行順序。
上面的事實(shí)可以證明下面的幾個(gè)常見的錯(cuò)誤。
6.2 案例二
雙重檢查鎖定嘗試避免同步帶來的開銷。比如下面的例子,twoprint 函數(shù)可能會(huì)被錯(cuò)誤地編寫為:
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 函數(shù)中,觀察到對(duì) done 的寫操作并不意味著能夠觀察到對(duì) a 的寫操作。上面的寫法依然有可能打印出空字符串。
6.3 案例三
另一個(gè)常見的錯(cuò)誤用法是對(duì)某個(gè)值的循環(huán)檢查,比如下面的代碼:
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func main() {
go setup()
for !done {
}
print(a)
}
和上一個(gè)例子類似,main函數(shù)中觀察到對(duì) done 的寫操作并不意味著可以觀察到對(duì) a 的寫操作,因此上面的代碼依然可能會(huì)打印出空字符串。
更糟糕的是,由于兩個(gè) Go協(xié)程之間缺少同步事件,main 函數(shù)甚至可能永遠(yuǎn)無法觀察到對(duì) done 變量的寫操作,導(dǎo)致 main 中的 for 循環(huán)永遠(yuǎn)執(zhí)行下去。
上面這個(gè)錯(cuò)誤有一種變體,如下面的代碼所示:
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 函數(shù)觀察到 g != nil并且退出了它的 for 循環(huán),依然沒有辦法保證它可以觀察到被初始化的 g.msg 值。
避免上面幾個(gè)錯(cuò)誤用法的方式是一樣的:顯式使用同步語句。
7. 總結(jié)
通過上面所有的例子,不難看出解決多goroutine下共享數(shù)據(jù)可見性問題的方法是在訪問共享數(shù)據(jù)時(shí)候施加一定的同步措施。
以上就是Golang 內(nèi)存模型The Go Memory Model的詳細(xì)內(nèi)容,更多關(guān)于Go Memory Model的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Golang?Gin框架獲取請(qǐng)求參數(shù)的幾種常見方式
在我們平常添加路由處理函數(shù)之后,就可以在路由處理函數(shù)中編寫業(yè)務(wù)處理代碼了,但在此之前我們往往需要獲取請(qǐng)求參數(shù),本文就詳細(xì)的講解下gin獲取請(qǐng)求參數(shù)常見的幾種方式,需要的朋友可以參考下2024-02-02
golang實(shí)現(xiàn)多協(xié)程下載文件(支持?jǐn)帱c(diǎn)續(xù)傳)
本文主要介紹了golang實(shí)現(xiàn)多協(xié)程下載文件,文中通過示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2021-11-11
Golang實(shí)現(xiàn)for循環(huán)運(yùn)行超時(shí)后自動(dòng)退出的方法
for循環(huán)對(duì)大家來說應(yīng)該都不陌生,對(duì)于golang來說更是必不可少,所以下面這篇文章就來給大家介紹了關(guān)于Golang如何實(shí)現(xiàn)for循環(huán)運(yùn)行一段時(shí)間超時(shí)后自動(dòng)退出的相關(guān)資料,需要的朋友可以參考借鑒,下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧。2017-11-11

