Go語言記憶體模型
目錄:
名詞定義
執行體 - Go裡的Goroutine或Java中的Thread
背景介紹
記憶體模型的目的是為了定義清楚變數的讀寫在不同執行體裡的可見性。理解記憶體模型在併發程式設計中非常重要,因為程式碼的執行順序和書寫的邏輯順序並不會完全一致,甚至在編譯期間編譯器也有可能重排程式碼以最優化CPU執行, 另外還因為有CPU快取的存在,記憶體的資料不一定會及時更新,這樣對記憶體中的同一個變數讀和寫也不一定和期望一樣。
和Java的記憶體模型規範類似,Go語言也有一個記憶體模型,相對JMM來說,Go的記憶體模型比較簡單,Go的併發模型是基於CSP(Communicating Sequential Process)的,不同的Goroutine通過一種叫Channel的資料結構來通訊;Java的併發模型則基於多執行緒和共享記憶體,有較多的概念(violatie, lock, final, construct, thread, atomic等)和場景,當然java.util.concurrent併發工具包大大簡化了Java併發程式設計。
Go記憶體模型規範了在什麼條件下一個Goroutine對某個變數的修改一定對其它Goroutine可見。
Happens Before
在一個單獨的Goroutine裡,對變數的讀寫和程式碼的書寫順序一致。比如以下的程式碼:
package main
import (
"log"
)
var a, b, c int
func main() {
a = 1
b = 2
c = a + 2
log.Println(a, b, c)
}
儘管在編譯期和執行期,編譯器和CPU都有可能重排程式碼,比如,先執行b=2,再執行a=1,但c=a+2是保證在a=1後執行的。這樣最後的執行結果一定是1
2 3
,不會是1 2 2
。但下面的程式碼則可能會輸出0
0 0
,1 2 2
, 0
2 3
(b=2比a=1先執行), 1 2 3
等各種可能。
package main
import (
"log"
)
var a, b, c int
func main() {
go func() {
a = 1
b = 2
}()
go func() {
c = a + 2
}()
log.Println(a, b, c)
}
Happens-before 定義
Happens-before用來指明Go程式裡的記憶體操作的區域性順序。如果一個記憶體操作事件e1 happens-before e2,則e2 happens-after e1也成立;如果e1不是happens-before e2,也不是happens-after e2,則e1和e2是併發的。
在這個定義之下,如果以下情況滿足,則對變數(v)的記憶體寫操作(w)對一個記憶體讀操作(r)來說允許可見的:
- r不在w開始之前發生(可以是之後或併發);
- w和r之間沒有另一個寫操作(w’)發生;
為了保證對變數(v)的一個特定寫操作(w)對一個讀操作(r)可見,就需要確保w是r唯一允許的寫操作,於是如果以下情況滿足,則對變數(v)的記憶體寫操作(w)對一個記憶體讀操作(r)來說保證可見的:
- w在r開始之前發生;
- 所有其它對v的寫操作只在w之前或r之後發生;
可以看出後一種約定情況比前一種更嚴格,這種情況要求沒有w或r沒有其他的併發寫操作。
在單個Goroutine裡,因為肯定沒有併發,上面兩種情況是等價的。對變數v的讀操作可以讀到最近一次寫操作的值(這個應該很容易理解)。但在多個Goroutine裡如果要訪問一個共享變數,我們就必須使用同步工具來建立happens-before條件,來保證對該變數的讀操作能讀到期望的修改值。
要保證並行執行體對共享變數的順序訪問方法就是用鎖。Java和Go在這點上是一致的。
以下是具體的可被利用的Go語言的happens-before規則,從本質上來講,happens-before規則確定了CPU緩衝和主存的同步時間點(通過記憶體屏障等指令),從而使得對變數的讀寫順序可被確定–也就是我們通常說的“同步”。
同步方法
初始化
- 如果package p 引用了package q,q的init()方法 happens-before p (Java工程師可以對比一下final變數的happens-before規則)
- main.main()方法 happens-after所有package的init()方法結束。
建立Goroutine
- go語句建立新的goroutine happens-before 該goroutine執行(這個應該很容易理解)
package main
import (
"log"
"time"
)
var a, b, c int
func main() {
a = 1
b = 2
go func() {
c = a + 2
log.Println(a, b, c)
}()
time.Sleep(1 * time.Second)
}
利用這條happens-before,我們可以確定c=a+2
是happens-aftera=1和b=2
,所以結果輸出是可以確定的1
2 3
,但如果是下面這樣的程式碼,輸出就不確定了,有可能是1 2 3
或0
0 2
func main() {
go func() {
c = a + 2
log.Println(a, b, c)
}()
a = 1
b = 2
time.Sleep(1 * time.Second)
}
銷燬Goroutine
- Goroutine的退出並不保證happens-before任何事件。
var a string
func hello() {
go func() { a = "hello" }()
print(a)
}
上面程式碼因為a="hello"
沒有使用同步事件,並不能保證這個賦值被主goroutine可見。事實上,極度優化的Go編譯器甚至可以完全刪除這行程式碼go
func() { a = "hello" }()
。
Goroutine對變數的修改需要讓對其它Goroutine可見,除了使用鎖來同步外還可以用Channel。
Channel通訊
在Go程式設計中,Channel是被推薦的執行體間通訊的方法,Go的編譯器和執行態都會盡力對其優化。
- 對一個Channel的傳送操作(send) happens-before 相應Channel的接收操作完成
- 關閉一個Channel happens-before 從該Channel接收到最後的返回值0
- 不帶緩衝的Channel的接收操作(receive) happens-before 相應Channel的傳送操作完成
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
= "hello, world"
happens-before c <- 0
,print(a)
happens-after <-c
,
根據上面的規則1)以及happens-before的可傳遞性,a = "hello, world"
happens-beforeprint(a)
。
根據規則2)把c<-0
替換成close(c)
也能保證輸出hello,world
,因為關閉操作在<-c
接收到0之前傳送。
var c = make(chan int)
var a string
func f() {
a = "hello, world"
<-c
}
func main() {
go f()
c <- 0
print(a)
}
根據規則3),因為c是不帶緩衝的Channel,a = "hello, world"
happens-before <-c
happens-beforec
<- 0
happens-before print(a)
, 但如果c是緩衝佇列,如定義c
= make(chan int, 1)
, 那結果就不確定了。
鎖
sync
包實現了兩種鎖資料結構:
- sync.Mutex -> java.util.concurrent.ReentrantLock
- sync.RWMutex -> java.util.concurrent.locks.ReadWriteLock
其happens-before規則和Java的也類似:
- 任何sync.Mutex或sync.RWMutex 變數(l),定義 n < m, 第n次
l.Unlock()
happens-before 第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)
}
a = "hello, world"
happens-before l.Unlock()
happens-before
第二個 l.Lock()
happens-beforeprint(a)
Once
sync
包還提供了一個安全的初始化工具Once。還記得Java的Singleton設計模式,double-check,甚至triple-check的各種單例初始化方法嗎?Go則提供了一個標準的方法。
once.Do(f)
中的f()
happens-before 任何多個once.Do(f)呼叫的返回,且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()
}
上面的程式碼雖然呼叫兩次doprint()
,但實際上setup
只會執行一次,並且併發的once.Do(setup)
都會等待setup
返回後再繼續執行。
參考連結
相關文章
- 圖解Go語言記憶體分配圖解Go記憶體
- [譯]Go語言記憶體佈局Go記憶體
- Go 語言的手工記憶體管理Go記憶體
- Java記憶體模型FAQ(二) 其他語言,像C++,也有記憶體模型嗎?Java記憶體模型C++
- C語言-記憶體分配C語言記憶體
- C語言:記憶體使用C語言記憶體
- Go 語言社群新提案 arena,可優化記憶體分配Go優化記憶體
- C語言-記憶體管理之一[記憶體分配]C語言記憶體
- C語言-記憶體管理之二[記憶體指令]C語言記憶體
- C語言的記憶體分配C語言記憶體
- Rust語言記憶體管理之妙Rust記憶體
- C語言記憶體對齊C語言記憶體
- GO語言————6.12 通過記憶體快取來提升效能Go記憶體快取
- Go語言內幕(6):啟動和記憶體分配初始化Go記憶體
- C語言(動態記憶體分配)C語言記憶體
- C語言記憶體地址基礎C語言記憶體
- 記憶體模型記憶體模型
- Java記憶體模型(MESI、記憶體屏障、volatile和鎖及final記憶體語義)Java記憶體模型
- Go:記憶體管理與記憶體清理Go記憶體
- golang 快速入門 [5.2]-go 語言是如何執行的-記憶體概述Golang記憶體
- 聊聊 記憶體模型與記憶體序記憶體模型
- Java記憶體模型FAQ(一) 什麼是記憶體模型Java記憶體模型
- 深入理解GO語言之記憶體詳解Go記憶體
- Java 記憶體模型Java記憶體模型
- Java記憶體模型Java記憶體模型
- ffmpeg記憶體模型記憶體模型
- JVM記憶體模型JVM記憶體模型
- Java記憶體區域和記憶體模型Java記憶體模型
- JVM 記憶體模型 記憶體分配,JVM鎖JVM記憶體模型
- JVM記憶體結構、Java記憶體模型和Java物件模型JVM記憶體Java模型物件
- Go 語言 結構體Go結構體
- 初體驗 Go 語言Go
- C 語言結構體記憶體佈局問題結構體記憶體
- C語言結構體記憶體佈局問題C語言結構體記憶體
- Go語言基準測試(benchmark)三部曲之二:記憶體篇Go記憶體
- GO 記憶體對齊Go記憶體
- Go記憶體逃逸分析Go記憶體
- GO語言學習筆記之mac環境go語言配置Go筆記Mac