Golang面向併發的記憶體模型
面向併發的記憶體模型
在早期,CPU都是以單核的形式順序執行機器指令。Go語言的祖先C語言正是這種順序程式語言的代表。順序程式語言中的順序是指:所有的指令都是以序列的方式執行,在相同的時刻有且僅有一個CPU在順序執行程式的指令。
隨著處理器技術的發展,單核時代以提升處理器頻率來提高執行效率的方式遇到了瓶頸,目前各種主流的CPU頻率基本被鎖定在了3GHZ附近。單核CPU的發展的停滯,給多核CPU的發展帶來了機遇。相應地,程式語言也開始逐步向並行化的方向發展。Go語言正是在多核和網路化的時代背景下誕生的原生支援併發的程式語言。
常見的並行程式設計有多種模型,主要有多執行緒、訊息傳遞等。從理論上來看,多執行緒和基於訊息的併發程式設計是等價的。由於多執行緒併發模型可以自然對應到多核的處理器,主流的作業系統因此也都提供了系統級的多執行緒支援,同時從概念上講多執行緒似乎也更直觀,因此多執行緒程式設計模型逐步被吸納到主流的程式語言特性或語言擴充套件庫中。而主流程式語言對基於訊息的併發程式設計模型支援則相比較少,Erlang語言是支援基於訊息傳遞併發程式設計模型的代表者,它的併發體之間不共享記憶體。Go語言是基於訊息併發模型的集大成者,它將基於CSP模型的併發程式設計內建到了語言中,通過一個go關鍵字就可以輕易地啟動一個Goroutine,與Erlang不同的是Go語言的Goroutine之間是共享記憶體的。
Goroutine和系統執行緒
Goroutine是Go語言特有的併發體,是一種輕量級的執行緒,由go關鍵字啟動。在真實的Go語言的實現中,goroutine和系統執行緒也不是等價的。儘管兩者的區別實際上只是一個量的區別,但正是這個量變引發了Go語言併發程式設計質的飛躍。
首先,每個系統級執行緒都會有一個固定大小的棧(一般預設可能是2MB),這個棧主要用來儲存函式遞迴呼叫時引數和區域性變數。固定了棧的大小導致了兩個問題:一是對於很多隻需要很小的棧空間的執行緒來說是一個巨大的浪費,二是對於少數需要巨大棧空間的執行緒來說又面臨棧溢位的風險。針對這兩個問題的解決方案是:要麼降低固定的棧大小,提升空間的利用率;要麼增大棧的大小以允許更深的函式遞迴呼叫,但這兩者是沒法同時兼得的。相反,一個Goroutine會以一個很小的棧啟動(可能是2KB或4KB),當遇到深度遞迴導致當前棧空間不足時,Goroutine會根據需要動態地伸縮棧的大小(主流實現中棧的最大值可達到1GB)。因為啟動的代價很小,所以我們可以輕易地啟動成千上萬個Goroutine。
Go的執行時還包含了其自己的排程器,這個排程器使用了一些技術手段,可以在n個作業系統執行緒上多工排程m個Goroutine。Go排程器的工作和核心的排程是相似的,但是這個排程器只關注單獨的Go程式中的Goroutine。Goroutine採用的是半搶佔式的協作排程,只有在當前Goroutine發生阻塞時才會導致排程;同時發生在使用者態,排程器會根據具體函式只儲存必要的暫存器,切換的代價要比系統執行緒低得多。執行時有一個runtime.GOMAXPROCS
變數,用於控制當前執行正常非阻塞Goroutine的系統執行緒數目。
在Go語言中啟動一個Goroutine不僅和呼叫函式一樣簡單,而且Goroutine之間排程代價也很低,這些因素極大地促進了併發程式設計的流行和發展。
原子操作
所謂的原子操作就是併發程式設計中“最小的且不可並行化”的操作。通常,如果多個併發體對同一個共享資源進行的操作是原子的話,那麼同一時刻最多隻能有一個併發體對該資源進行操作。從執行緒角度看,在當前執行緒修改共享資源期間,其它的執行緒是不能訪問該資源的。原子操作對於多執行緒併發程式設計模型來說,不會發生有別於單執行緒的意外情況,共享資源的完整性可以得到保證。
一般情況下,原子操作都是通過“互斥”訪問來保證的,通常由特殊的CPU指令提供保護。當然,如果僅僅是想模擬下粗粒度的原子操作,我們可以藉助於sync.Mutex
來實現:
import ( "sync" ) var total struct { sync.Mutex value int } func worker(wg *sync.WaitGroup) { defer wg.Done() for i := 0; i <= 100; i++ { total.Lock() total.value += i total.Unlock() } } func main() { var wg sync.WaitGroup wg.Add(2) go worker(&wg) go worker(&wg) wg.Wait() fmt.Println(total.value) }
在worker
的迴圈中,為了保證total.value += i
的原子性,我們通過sync.Mutex
加鎖和解鎖來保證該語句在同一時刻只被一個執行緒訪問。對於多執行緒模型的程式而言,進出臨界區前後進行加鎖和解鎖都是必須的。如果沒有鎖的保護,total
的最終值將由於多執行緒之間的競爭而可能會不正確。
用互斥鎖來保護一個數值型的共享資源,麻煩且效率低下。標準庫的sync/atomic
包對原子操作提供了豐富的支援。我們可以重新實現上面的例子:
import ( "sync" "sync/atomic" ) var total uint64 func worker(wg *sync.WaitGroup) { defer wg.Done() var i uint64 for i = 0; i <= 100; i++ { atomic.AddUint64(&total, i) } } func main() { var wg sync.WaitGroup wg.Add(2) go worker(&wg) go worker(&wg) wg.Wait() }
atomic.AddUint64
函式呼叫保證了total
的讀取、更新和儲存是一個原子操作,因此在多執行緒中訪問也是安全的。
原子操作配合互斥鎖可以實現非常高效的單件模式。互斥鎖的代價比普通整數的原子讀寫高很多,在效能敏感的地方可以增加一個數字型的標誌位,通過原子檢測標誌位狀態降低互斥鎖的使用次數來提高效能。
type singleton struct {} var ( instance*singleton initialized uint32 musync.Mutex ) func Instance() *singleton { if atomic.LoadUint32(&initialized) == 1 { return instance } mu.Lock() defer mu.Unlock() if instance == nil { defer atomic.StoreUint32(&initialized, 1) instance = &singleton{} } return instance }
我們可以將通用的程式碼提取出來,就成了標準庫中sync.Once
的實現:
type Once struct { mMutex done uint32 } func (o *Once) Do(f func()) { if atomic.LoadUint32(&o.done) == 1 { return } o.m.Lock() defer o.m.Unlock() if o.done == 0 { defer atomic.StoreUint32(&o.done, 1) f() } }
基於sync.Once
重新實現單件模式:
var ( instance *singleton oncesync.Once ) func Instance() *singleton { once.Do(func() { instance = &singleton{} }) return instance }
sync/atomic
包對基本的數值型別及複雜物件的讀寫都提供了原子操作的支援。atomic.Value
原子物件提供了Load
和Store
兩個原子方法,分別用於載入和儲存資料,返回值和引數都是interface{}
型別,因此可以用於任意的自定義複雜型別。
var config atomic.Value // 儲存當前配置資訊 // 初始化配置資訊 config.Store(loadConfig()) // 啟動一個後臺執行緒, 載入更新後的配置資訊 go func() { for { time.Sleep(time.Second) config.Store(loadConfig()) } }() // 用於處理請求的工作者執行緒始終採用最新的配置資訊 for i := 0; i < 10; i++ { go func() { for r := range requests() { c := config.Load() // ... } }() }
這是一個簡化的生產者消費者模型:後臺執行緒生成最新的配置資訊;前臺多個工作者執行緒獲取最新的配置資訊。所有執行緒共享配置資訊資源。
順序一致性記憶體模型
如果只是想簡單地線上程之間進行資料同步的話,原子操作已經為程式設計人員提供了一些同步保障。不過這種保障有一個前提:順序一致性的記憶體模型。要了解順序一致性,我們先看看一個簡單的例子:
var a string var done bool func setup() { a = "hello, world" done = true } func main() { go setup() for !done {} print(a) }
我們建立了setup
執行緒,用於對字串a
的初始化工作,初始化完成之後設定done
標誌為true
。main
函式所在的主執行緒中,通過for !done {}
檢測done
變為true
時,認為字串初始化工作完成,然後進行字串的列印工作。
但是Go語言並不保證在main
函式中觀測到的對done
的寫入操作發生在對字串a
的寫入的操作之後,因此程式很可能列印一個空字串。更糟糕的是,因為兩個執行緒之間沒有同步事件,setup
執行緒對done
的寫入操作甚至無法被main
執行緒看到,main
函式有可能陷入死迴圈中。
在Go語言中,同一個Goroutine執行緒內部,順序一致性記憶體模型是得到保證的。但是不同的Goroutine之間,並不滿足順序一致性記憶體模型,需要通過明確定義的同步事件來作為同步的參考。如果兩個事件不可排序,那麼就說這兩個事件是併發的。為了最大化並行,Go語言的編譯器和處理器在不影響上述規定的前提下可能會對執行語句重新排序(CPU也會對一些指令進行亂序執行)。
因此,如果在一個Goroutine中順序執行a = 1; b = 2;
兩個語句,雖然在當前的Goroutine中可以認為a = 1;
語句先於b = 2;
語句執行,但是在另一個Goroutine中b = 2;
語句可能會先於a = 1;
語句執行,甚至在另一個Goroutine中無法看到它們的變化(可能始終在暫存器中)。也就是說在另一個Goroutine看來,a = 1; b = 2;
兩個語句的執行順序是不確定的。如果一個併發程式無法確定事件的順序關係,那麼程式的執行結果往往會有不確定的結果。比如下面這個程式:
func main() { go println("你好, 世界") }
根據Go語言規範,main
函式退出時程式結束,不會等待任何後臺執行緒。因為Goroutine的執行和main
函式的返回事件是併發的,誰都有可能先發生,所以什麼時候列印,能否列印都是未知的。
用前面的原子操作並不能解決問題,因為我們無法確定兩個原子操作之間的順序。解決問題的辦法就是通過同步原語來給兩個事件明確排序:
func main() { done := make(chan int) go func(){ println("你好, 世界") done <- 1 }() <-done }
當<-done
執行時,必然要求done <- 1
也已經執行。根據同一個Gorouine依然滿足順序一致性規則,我們可以判斷當done <- 1
執行時,println("你好, 世界")
語句必然已經執行完成了。因此,現在的程式確保可以正常列印結果。
當然,通過sync.Mutex
互斥量也是可以實現同步的:
func main() { var mu sync.Mutex mu.Lock() go func(){ println("你好, 世界") mu.Unlock() }() mu.Lock() }
可以確定後臺執行緒的mu.Unlock()
必然在println("你好, 世界")
完成後發生(同一個執行緒滿足順序一致性),main
函式的第二個mu.Lock()
必然在後臺執行緒的mu.Unlock()
之後發生(sync.Mutex
保證),此時後臺執行緒的列印工作已經順利完成了。
初始化順序
前面函式章節中我們已經簡單介紹過程式的初始化順序,這是屬於Go語言面向併發的記憶體模型的基礎規範。
Go程式的初始化和執行總是從main.main
函式開始的。但是如果main
包裡匯入了其它的包,則會按照順序將它們包含進main
包裡(這裡的匯入順序依賴具體實現,一般可能是以檔名或包路徑名的字串順序匯入)。如果某個包被多次匯入的話,在執行的時候只會匯入一次。當一個包被匯入時,如果它還匯入了其它的包,則先將其它的包包含進來,然後建立和初始化這個包的常量和變數。然後就是呼叫包裡的init
函式,如果一個包有多個init
函式的話,實現可能是以檔名的順序呼叫,同一個檔案內的多個init
則是以出現的順序依次呼叫(init
不是普通函式,可以定義有多個,所以不能被其它函式呼叫)。最終,在main
包的所有包常量、包變數被建立和初始化,並且init
函式被執行後,才會進入main.main
函式,程式開始正常執行
要注意的是,在main.main
函式執行之前所有程式碼都執行在同一個Goroutine中,也是執行在程式的主系統執行緒中。如果某個init
函式內部用go關鍵字啟動了新的Goroutine的話,新的Goroutine和main.main
函式是併發執行的。
因為所有的init
函式和main
函式都是在主執行緒完成,它們也是滿足順序一致性模型的。
Goroutine的建立
go
語句會在當前Goroutine對應函式返回前建立新的Goroutine. 例如:
var a string func f() { print(a) } func hello() { a = "hello, world" go f() }
執行go f()
語句建立Goroutine和hello
函式是在同一個Goroutine中執行, 根據語句的書寫順序可以確定Goroutine的建立發生在hello
函式返回之前, 但是新建立Goroutine對應的f()
的執行事件和hello
函式返回的事件則是不可排序的,也就是併發的。呼叫hello
可能會在將來的某一時刻列印"hello, world"
,也很可能是在hello
函式執行完成後才打印。
基於Channel的通訊
Channel通訊是在Goroutine之間進行同步的主要方法。在無快取的Channel上的每一次傳送操作都有與其對應的接收操作相配對,傳送和接收操作通常發生在不同的Goroutine上(在同一個Goroutine上執行2個操作很容易導致死鎖)。無快取的Channel上的傳送操作總在對應的接收操作完成前發生.
var done = make(chan bool) var msg string func aGoroutine() { msg = "你好, 世界" done <- true } func main() { go aGoroutine() <-done println(msg) }
可保證打印出“hello, world”。該程式首先對msg
進行寫入,然後在done
管道上傳送同步訊號,隨後從done
接收對應的同步訊號,最後執行println
函式。
若在關閉Channel後繼續從中接收資料,接收者就會收到該Channel返回的零值。因此在這個例子中,用close(c)
關閉管道代替done <- false
依然能保證該程式產生相同的行為。
var done = make(chan bool) var msg string func aGoroutine() { msg = "你好, 世界" close(done) } func main() { go aGoroutine() <-done println(msg) }
對於從無緩衝Channel進行的接收,發生在對該Channel進行的傳送完成之前。
基於上面這個規則可知,交換兩個Goroutine中的接收和傳送操作也是可以的(但是很危險):
var done = make(chan bool) var msg string func aGoroutine() { msg = "hello, world" <-done } func main() { go aGoroutine() done <- true println(msg) }
也可保證打印出“hello, world”。因為main
執行緒中done <- true
傳送完成前,後臺執行緒<-done
接收已經開始,這保證msg = "hello, world"
被執行了,所以之後println(msg)
的msg已經被賦值過了。簡而言之,後臺執行緒首先對msg
進行寫入,然後從done
中接收訊號,隨後main
執行緒向done
傳送對應的訊號,最後執行println
函式完成。但是,若該Channel為帶緩衝的(例如,done = make(chan bool, 1)
),main
執行緒的done <- true
接收操作將不會被後臺執行緒的<-done
接收操作阻塞,該程式將無法保證打印出“hello, world”。
對於帶緩衝的Channel,
對於Channel的第K
個接收完成操作發生在第K+C
個傳送操作完成之前,其中C
是Channel的快取大小。
如果將C
設定為0自然就對應無快取的Channel,也即使第K個接收完成在第K個傳送完成之前。因為無快取的Channel只能同步發1個,也就簡化為前面無快取Channel的規則:對於從無緩衝Channel進行的接收,發生在對該Channel進行的傳送完成之前。
我們可以根據控制Channel的快取大小來控制併發執行的Goroutine的最大數目, 例如:
var limit = make(chan int, 3) func main() { for _, w := range work { go func() { limit <- 1 w() <-limit }() } select{} }
最後一句select{}
是一個空的管道選擇語句,該語句會導致main
執行緒阻塞,從而避免程式過早退出。還有for{}
、<-make(chan int)
等諸多方法可以達到類似的效果。因為main
執行緒被阻塞了,如果需要程式正常退出的話可以通過呼叫os.Exit(0)
實現。
不靠譜的同步
前面我們已經分析過,下面程式碼無法保證正常列印結果。實際的執行效果也是大概率不能正常輸出結果。
func main() { go println("你好, 世界") }
剛接觸Go語言的話,可能希望通過加入一個隨機的休眠時間來保證正常的輸出:
func main() { go println("hello, world") time.Sleep(time.Second) }
因為主執行緒休眠了1秒鐘,因此這個程式大概率是可以正常輸出結果的。因此,很多人會覺得這個程式已經沒有問題了。但是這個程式是不穩健的,依然有失敗的可能性。我們先假設程式是可以穩定輸出結果的。因為Go執行緒的啟動是非阻塞的,main
執行緒顯式休眠了1秒鐘退出導致程式結束,我們可以近似地認為程式總共執行了1秒多時間。現在假設println
函式內部實現休眠的時間大於main
執行緒休眠的時間的話,就會導致矛盾:後臺執行緒既然先於main
執行緒完成列印,那麼執行時間肯定是小於main
執行緒執行時間的。當然這是不可能的。
嚴謹的併發程式的正確性不應該是依賴於CPU的執行速度和休眠時間等不靠譜的因素的。嚴謹的併發也應該是可以靜態推匯出結果的:根據執行緒內順序一致性,結合Channel或sync
同步事件的可排序性來推導,最終完成各個執行緒各段程式碼的偏序關係排序。如果兩個事件無法根據此規則來排序,那麼它們就是併發的,也就是執行先後順序不可靠的。
解決同步問題的思路是相同的:使用顯式的同步。