Go基於共享變數的併發
在前一章中,我們介紹了幾個使用goroutines和channel以直接和自然的方式表示併發的程式。然而,在這樣做的過程中,我們忽略了程式設計師在編寫併發程式碼時必須牢記的一些重要而微妙的問題。
在本章中,我們將更深入地瞭解併發性的機制。特別地,我們將指出與多個goroutines之間共享變數相關的一些問題,識別這些問題的分析技術,以及解決這些問題的模式。最後,我們將解釋goroutines和作業系統執行緒之間的一些技術差異。
9.1 Race Conditions 競態條件
在序列/順序執行的程式中,即只有一個goroutine的程式,程式執行的步驟按照程式邏輯確定的執行順序發生。例如,在一系列的序列/順序語句中,第一個語句hanppen before【發生先於】第二個語句,以此類推。在一個有兩個或多個goroutine的程式中,每個goroutine中的步驟按照既定的順序發生,但通常我們不知道一個goroutine中的x事件是在另一個goroutine的y事件之前發生的,還是在它之後發生的,或者是同時發生的。當我們不能自信地說一個事件hanppen before【發生先於】另一個事件的發生時,那麼事件x和y就是同時發生的。
考慮一個在序列/順序執行的程式中執行的函式。如果該函式即使在併發呼叫時仍能正常工作(即從兩個或多個goroutines呼叫而不需要額外的同步),那麼它就是併發安全【concurrency-safe】的。我們可以將此概念推廣到一組協作函式,例如特定型別的方法和操作。如果一個型別的所有可訪問的方法和操作都是併發安全的,那麼該型別也是併發安全的。
我們可以不需要使程式中的所有具體型別都併發安全,就可以實現程式的併發安全。實際上,併發安全型別是例外,而不是規則,因此只有在其型別的文件中表明該型別這是安全的情況下,我們才應該併發地訪問變數。我們可以通過將重要的變數限制在一個單獨的goroutine中,或者通過保持互斥的高階不變數來避免對重要的變數的併發訪問。我們將在本章中解釋這些術語。
相反,匯出的包級別的函式通常被期望為併發安全的。由於包級別的變數不能限制在單個goroutine中執行,因此修改它們的函式必須強制執行互斥。
當併發訪問時,有多種原因會導致函式不能正常的工作,這些原因包括死鎖【deadlive】、活鎖【livelock】以及資源飢餓【resource starvation】。我們沒有時間來討論這所有的會導致程式非正常執行的原因,因此我們將注意力關注於最重要的一點,即競態條件。
競態條件是指程式由於多個goroutine的檢查操作導致沒有給出正確結果的情況。競態條件是有害的,因為它們可能隱藏在程式中,很少出現,可能只在高負載或使用某些特定的編譯器、平臺或體系結構時才出現。這使得它們難以重現和診斷。
我們一般使用元資料或者財務損失來表述它的重要性,所以我們將考慮一個簡單的銀行賬戶程式。
// Package bank implements a bank with only one account.
package bank
var balance int
func Deposit(amount int) { balance = balance + amount }
func Balance() int { return balance }
(我們可以把Deposit(存款的意思)函式的主體寫成balance += amount,這是等價的,但較長的形式將簡化解釋)。
對於這個簡單的程式,我們一眼就可以看出來,任何序列/順序的呼叫Deposit,Balance函式都會給出正確的結果,也就是說Balance會報告出之前存款的總額。然而,如果我們併發的呼叫Deposit函式,那麼Balance就不能保證會給出正確的結果了。讓我們看下面這兩個goroutines,它們代表的是在一個聯合銀行帳戶上的兩筆交易:
// Alice:
go func() {
bank.Deposit(200) // A1
fmt.Println("=", bank.Balance()) // A2
}()
// Bob:
go bank.Deposit(100) // B
Alice存了$200,然後檢查了餘額,這時Bob也存了$100。因為步驟A1,A2與B是併發發生的,我們無法預測它們發生的順序。直覺上,有三種可能的熟悉怒,分別是“Alice先”,“”Bob先“,“”Alice/Bob/Alice”。下面的表展示了balance變數在每一個步驟之後的值。雙引號代表的是列印的餘額。
Alice First | Bob First | Alice/Bob/Alice |
---|---|---|
0 | 0 | 0 |
A1 200 | B 100 | A1 200 |
A2 “= 200” | A1 300 | B 300 |
B 300 | A2 “= 300” | A2 “= 300” |
在所有情況最後,balance都是$300。唯一的變化是Alice的資產負債表是否包含Bob的交易,但是客戶對這些情況都很滿意。
但這種直覺是錯誤的。還有第四種可能的結果,Bob的存款發生在Alice存款的中間,在餘額被讀取( balance + amount)之後,但是在餘額被更新(balance =…)之前,這將導致Bob的事務消失。這是因為Alice的存款操作A1,實際上是兩個操作,一個讀和一個寫;我們稱它們為A1r和A1w。這裡有一個有問題的交叉:
Data race
0
A1r 0 ... = balance + amount
B 100
A1w 200 balance = ...
A2 "= 200"
在A1r後,表示式balance + amount計算得到200,這個值會在A1w時候被寫入到balance,儘管這中間有介入的存款。最終的餘額是$200,銀行因為Bob而富有了$100。
這個程式包含一個特殊型別的競態條件,我們稱之為資料競爭(data race)。當兩個goroutine併發的訪問相同的變數
當兩個goroutines同時訪問同一個變數且至少有一個訪問是寫操作時,就會發生資料爭用。
如果資料競態涉及的資料的型別是一個比單個機器字還要大的型別(如介面、字串或切片),事情就會變得更加混亂。下面這段程式碼併發地將x更新為兩個不同長度的切片:
var x []int
go func() { x = make([]int, 10) }()
go func() { x = make([]int, 1000000) }()
x[999999] = 1 // NOTE: undefined behavior; memory corruption possible!
最後一個語句中的x值沒有被明確定義,它可能是nil,或者是length為10的切片,或者length為1,000,000的切片。但是回想一下,一個切片有三個部分:指標、長度和容量。如果指標來自第一個執行的make呼叫,而長度來自第二個make呼叫,那麼x將是一個嵌合體,即一個名義長度為1,000,000的切片,但其底層陣列只有10個元素。在這種情況下,儲存元素到999,999將會破壞一個任意遙遠的記憶體地址,其後果是無法預測的,也很難除錯和本地化。這種語義雷區稱為未定義行為【undefined behavior】,C程式設計師都知道;因為總的來說,在Go中很少有像在C語言中一樣的詞。
即使直覺上認為併發程式是幾個順序執行的程式的交錯,這也是錯誤的。正如我們將在9.4章節中看到的那樣,資料競態可能會產生更奇怪的結果。許多程式設計師—甚至一些非常聰明的人—他們偶爾會為程式中存在已知的資料競態提供辯解:“一次性排除的成本太高了”,“這塊邏輯只用於日誌記錄”,“我不介意丟失一些訊息”等等。在給定的編譯器和平臺上沒有出現問題,這可能會給他們提供了錯誤的信心。一個好的經驗法則是,沒有良性的資料競態。那麼我們如何避免程式中的資料競態呢?
我們將重複這個定義,因為它非常重要:當兩個goroutines同時訪問同一個變數且至少有一個訪問是寫操作時,就會發生資料競態。根據這個定義,有三種方法可以避免資料競態。
第一種方法是不要向變數做寫操作。考慮下面的map,由於每個鍵都是第一次請求的,因此它被延遲填充。如果按順序呼叫 Icon,程式執行正常,但如果同時呼叫 Icon,就會出現資料競態。
var icons = make(map[string]image.Image)
func loadIcon(name string) image.Image
// NOTE: not concurrency-safe!
func Icon(name string) image.Image {
icon, ok := icons[name]
if !ok {
icon = loadIcon(name)
icons[name] = icon
}
return icon
}
如果我們在建立額外的goroutines之前,用所有必要的條目初始化這個map,並且不再修改它,那麼任意數量的goroutines都可以安全地同時呼叫Icon,因為它們都只讀取map。
var icons = map[string]image.Image{
"spades.png": loadIcon("spades.png"),
"hearts.png": loadIcon("hearts.png"),
"diamonds.png": loadIcon("diamonds.png"),
"clubs.png": loadIcon("clubs.png"),
}
// Concurrency-safe.
func Icon(name string) image.Image { return icons[name] }
在上面的例子中,icon變數在包初始化的時候就被賦值了,而包初始化是happen before【發生先於】程式的main函式。一旦被初始化了,icon就不會被更改了。從不修改或不可變的資料結構在本質上是併發安全的,不需要同步。但顯然,如果更新是必要的,我們就不能使用這種方法,就像銀行賬戶一樣。
避免資料競態的第二種方法是避免從多個goroutines訪問變數。這是上一章中許多程式所採用的方法。例如,併發網路爬蟲(§8.6)中的main goroutine是唯一一個訪問seen所對應的map的goroutine,還有在聊天伺服器(§8.10)中,執行broadcaster函式的goroutine是唯一一個訪問clients這個map的goroutine。這些變數被限制於僅被一個單獨的goroutine訪問。
由於其他goroutine不能直接訪問該變數,它們必須使用一個Channel向受限的goroutine傳送一個請求來查詢或更新變數。這就是Go經典的口頭禪“不要通過共享記憶來通訊;取而代之,通過通訊來共享記憶體“的意思。通過使用Channel請求,代理了對受限變數的訪問,這樣的goroutine被稱為該變數的monitor goroutine。例如,執行broadcaster函式的goroutine,監視對clients所對應的map的訪問。
下面是重寫的銀行示例,balance變數被限制於僅被稱為teller的監視器例程訪問。
// Package bank provides a concurrency-safe bank with one account.
package bank
var deposits = make(chan int) // send amount to deposit
var balances = make(chan int) // receive balance
func Deposit(amount int) { deposits <- amount }
func Balance() int { return <-balances }
func teller() {
var balance int // balance is confined to teller goroutine
for {
select {
case amount := <-deposits:
balance += amount
case balances <- balance:
}
}
}
func init() {
go teller() // start the monitor goroutine
}
即使變數不能在其整個生命週期都內被限制僅在單個goroutine中訪問,限制訪問仍然可能是併發訪問問題的解決方案。例如,通過Channel將變數的地址從一個階段傳遞到下一個階段,這是在處於Pipeline中的goroutines之間共享變數的很常見的方式。如果Pipeline的每個階段在將變數傳送到下一個階段後都不訪問該變數,那麼對該變數的所有訪問都是序列/順序的。實際上,變數首先被限制在管道的一個階段,然後又被限制在另一個階段,以此類推。這種紀律有時被稱為連環監禁【 serial confinement.】
讓我們看下面的例子,Cakes就是連環監禁,他首先被限制於執行baker函式的goroutione,然後是執行icer函式的goroutine:
type Cake struct{ state string }
func baker(cooked chan<- *Cake) {
for {
cake := new(Cake)
cake.state = "cooked"
cooked <- cake // baker never touches this cake again
}
}
func icer(iced chan<- *Cake, cooked <-chan *Cake) {
for cake := range cooked {
cake.state = "iced"
iced <- cake // icer never touches this cake again
}
}
第三種避免資料競態的方式,是可以允許多個goroutine訪問變數,但是一次只允許一個訪問。這種方法也被稱為互斥【 mutual exclusion】,下一節講。
9.2 Mutual Exclusion: sync.Mutex
在8.6節,我們使用一個帶緩衝的Channel作為一個計數訊號量[counting semaphore],來保證不會有超過20個goroutine同時發起HTTP請求。同樣的道理,我們可以使用一個容量為1的Channel,來保證不會同時有超過1個的goroutine去訪問共享變數。計數僅為1的訊號量也被稱為二元訊號量【binary semaphore】。
var (
sema = make(chan struct{}, 1) // a binary semaphore guarding balance
balance int
)
func Deposit(amount int) {
sema <- struct{}{} // acquire token
balance = balance + amount
<-sema // release token
}
func Balance() int {
sema <- struct{}{} // acquire token
b := balance
<-sema // release token
return b
}
因為互斥很有用,所以sync包直接使用Mutex型別來支援了這一特性。它的Lock方法獲取token(也稱為lock),它的Unlock方法則會釋放該鎖。
import "sync"
var (
mu sync.Mutex // guards balance
balance int
)
func Deposit(amount int) {
mu.Lock()
balance = balance + amount
mu.Unlock()
}
func Balance() int {
mu.Lock()
b := balance
mu.Unlock()
return b
}
每次當goroutine訪問銀行系統中的變數時(我們例子中僅指的是balance),它必須呼叫mutex的Lock方法,來獲取一個獨有排他的鎖。如果有其他goroutine已經獲取了鎖,那麼這個操作將會阻塞,直到其他的goroutine呼叫Unlock釋放了鎖為止。互斥鎖保護共享變數。按照慣例,由互斥鎖保護的變數在互斥鎖本身宣告之後應該立即宣告(如上例中的互斥鎖mu和共享變數balance)。如果你偏離違背了這一點,一定要記錄下來。
在Lock和Unlock之間的程式碼區域中,goroutine可以自由地讀取和修改共享變數,稱為臨界區【critical section】。在其他goroutine能夠自由的獲得鎖之前,當前鎖持有者必須呼叫Unlock。當goroutine結束後,釋放鎖是它必須要做的事,無論函式執行是夠成功。
上面的銀行程式演示了一種常見的併發模式。一組匯出的函式封裝了一個或多個變數,因此訪問變數的唯一方法是通過這些函式(或方法)。每一個函式會在開始執行時,獲取一個互斥所,並在函式結束時釋放這個鎖,因此可以保證共享的變數可以併發的訪問。這種函式、互斥鎖和變數的排列組合方式稱為監視器。(我們在監視器例程【monitor goroutine】中也提到了monitor這個詞。這兩種方法都使用了代理,以確保變數被按順序訪問。)
因為Deposit函式和Balance函式中臨界區太小了-----只有一行,也沒有分支-----我們可以在最後直接了當的呼叫Unlock。在很多複雜的臨界區中,特別是那些必須通過提前返回來處理錯誤的情況下,很難分辨所有情況下,加鎖與釋放鎖是否都成對執行的。Go的延遲宣告來拯救:通過延遲對Unlock的呼叫,臨界區隱式地擴充套件到了當前函式的末尾,這樣我們就不用記得在一個或多個遠離Lock呼叫的地方插入Unlock呼叫了.
func Balance() int {
mu.Lock()
defer mu.Unlock()
return balance
}
在上面的例子,Unlock會在return語句讀取了balance的值之後執行,因此Balance函式是併發安全的。另外,我們不再需要區域性變數b了。
此外,即使臨界區內發生了恐慌,延遲Unlock也可以保證被執行,這對於使用recover(§5.10)的程式至關重要。defer的執行成本比顯式呼叫Unlock要稍微昂貴一些,但這不足以成為程式碼不夠清晰的佐證。與其他併發程式一樣,要優先支援清晰性,避免過早的優化。在可能的情況下,使用defer,並讓臨界區擴充套件到函式的末尾。
讓我們來考慮下面的withdraw函式。當成功時,他會將餘額減少指定的數額,並返回true,但如果賬戶沒有足夠的資金進行交易,則恢復餘額並返回false。
// NOTE: not atomic!
func Withdraw(amount int) bool {
Deposit(-amount)
if Balance() < 0 {
Deposit(amount)
return false // insufficient funds
}
return true
}
這個函式最終給出了正確的結果,但是它有一個令人討厭的副作用。當試圖超額提款時,餘額會暫時地降至零以下。而這可能會導致併發發起的一筆取款被拒絕(因為此時餘額小於0)。所以,如果鮑勃想買一輛跑車,愛麗絲就付不起她的咖啡錢。問題是,提款操作不是原子操作:它由三個獨立的操作序列組成,每個操作序列都會獲取並釋放這個互斥鎖,但沒有什麼能鎖住整個執行序列。
理想情況下,Withdraw應該在整個操作中只獲取互斥鎖一次。但是這種嘗試沒有用:
// NOTE: incorrect!
func Withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()
Deposit(-amount)
if Balance() < 0 {
Deposit(amount)
return false // insufficient funds
}
return true
}
Deposit試圖通過呼叫mu.Lock()再次獲得互斥鎖,但是由於互斥鎖不是可重入的,因此不可能鎖定已經鎖定的互斥鎖,這會導致死鎖,而無法繼續進行任何操作,而Withdraw會永遠阻塞。
Go中的互斥鎖不具有可重入是有原因的。互斥的目的是確保共享變數的某些不變數
互斥鎖的目的是確保共享變數的特定不變數(invariants)在程式執行的臨界點得到維護。其中一個不變數就是“沒有goroutine正在訪問共享的變數”,但是對於互斥鎖所保護的資料結構,可能會有額外的不變數(invariants)。當goroutine獲得互斥鎖時,它可能假設這些不變數是滿足的。當它持有鎖時,它可能會更新共享變數,這樣可能會臨時違反不變數。但是,當它釋放鎖時,它必須保證秩序已經恢復,並且不變數再次滿足。一個可重入互斥鎖將確保沒有其他goroutine訪問共享變數,但它不能保護這些變數的其他不變數。
一種常見的解決方案是將一個函式(如Deposit)劃分為兩個:一個未匯出的函式,deposit,該函式假定已經持有了鎖,只執行實際的工作,另一個是一個匯出的函式,Deposit,即在呼叫deposit之前獲取鎖。這樣我們就可以用存款的方式來表示取款:
func Withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()
deposit(-amount)
if balance < 0 {
deposit(amount)
return false // insufficient funds
}
return true
}
func Deposit(amount int) {
mu.Lock()
defer mu.Unlock()
deposit(amount)
}
func Balance() int {
mu.Lock()
defer mu.Unlock()
return balance
}
// This function requires that the lock be held.
func deposit(amount int) { balance += amount }
當然,這裡顯示的存款(Deposit)函式非常簡單,一個實際的取款(Withdraw)函式不會麻煩地呼叫它,但它說明了這個原則。
封裝(§6.6),通過減少程式中非預期的互動,幫助我們維護資料結構不變數。出於同樣的原因,封裝也可以幫助我們維護併發不變數。當您使用互斥時,請確保它和它保護的變數都沒有匯出,無論它們是包級別的變數還是結構體的屬性欄位。
9.3 讀寫互斥鎖:sync.RWMutex
在看到自己的100美元存款消失得無影無蹤後,鮑勃感到一陣焦慮,他寫了一個程式,每秒數百次檢查自己的銀行存款餘額。他在家裡、單位裡、手機上執行它的程式。銀行觀察到快速增長的業務請求正在拖慢存款與借款操作,因為所有的Balance請求都是序列執行的,持有互斥鎖,並暫時妨礙了其他的goroutine執行。
因為Balance函式只需讀取變數的狀態即可,所以多個Balance請求實際上可以安全的併發執行,只要Deposit和Withdraw請求沒有同時執行即可。在這種場景下,我們需要一個特殊型別的鎖,來允許只讀操作彼此並行進行,但是寫操作具有完全獨佔的訪問權。這種鎖也被稱為多讀單寫鎖【multiple readers, single writer】。Go中的sunv.RWMutex提供了這種功能。
var mu sync.RWMutex
var balance int
func Balance() int {
mu.RLock() // readers lock
defer mu.RUnlock()
return balance
}
Balanace函式現在通過吊桶RLock來獲取讀鎖,通過RUnlock來釋放讀鎖(讀鎖也稱為共享鎖)。Deposit函式無需更改,它通過呼叫mu.Lock和mu.Unlock來分別獲取和釋放寫鎖(寫鎖也稱為互斥鎖)。
經過修改後,Bob的絕大多數請求都會並行的執行,且可以很快的完成。鎖可以在更多的時間內使用,並且Deposit(存款)請求可以及時的得到響應。
只有在臨界區內沒有對共享變數做寫操作時,才能使用RLock。通常,我們不應該假設邏輯上只讀的函式或方法不會更新一些變數。例如,一個看似簡單的訪問器的方法可能還會遞增一個內部使用計數器,或者更新一個快取,使重複呼叫更快(如記賬單例模式)。如果有疑問,使用獨佔鎖。
僅在絕大部分goroutine都是獲取讀鎖,並且鎖競爭比較激烈時(即goroutine一版都需要等待才會獲取到鎖),RWMutex才有優勢。因為RWMutex需要更復雜的內部簿記工作,所以在競爭不激烈時他比普通的互斥鎖要慢。
9.4 記憶體同步
您可能想知道為什麼Balance方法需要基於Channel或基於互斥鎖的互斥。畢竟,與Deposit不同的是,它只包含一個操作,所以不存在另一個goroutine在“中間”執行的危險。我們需要互斥鎖有兩個原因。首先,防止Balance函式插入到其他其他操作(如Withdraw)的“中間”,這也是很重要的。第二個(也是更微妙的)原因是同步不僅涉及多個goroutine的執行順序;同步也會影響記憶體。
在現代計算機上,可能有多個處理器,每一個處理器都有其自己關於主存的本地快取。為了提高效率,對記憶體的寫操作會快取在每個處理器中,並僅在必要時將其重新整理到主存。甚至刷回主存的順序都可能與goroutine的寫入順序不一致 。像Channel通訊和互斥鎖操作這樣的同步原語會導致處理器重新整理並提交所有累積的寫操作,從而保證在那一點上執行的goroutine的執行的效果對執行在其他處理器上的goroutine是可見的。
考慮下面的程式碼片段的輸出:
var x, y int
go func() {
x = 1 // A1
fmt.Print("y:", y, " ") // A2
}()
go func() {
y = 1 // B1
fmt.Print("x:", x, " ") // B2
}()
因為這個兩個goroutine是併發執行的,且都在沒有使用互斥鎖的情況下訪問了共享變數,這裡有一個數據競態,所以我們不會驚訝於程式的結果是不確定的。根據對程式中標註語句的不同交錯模式,我們可能期望它會輸出這四個結果中的任何一個:
y:0 x:1
x:0 y:1
x:1 y:1
y:1 x:1
第四行可以是A1 B1 A2 B2或者是B1 A1 A2 B2這樣的執行順序。無論如何,程式產生的如下兩個輸出就出乎意料了:
x:0 y:0
y:0 x:0
但是在某些編譯器,CPU或者其他因素的條件下,這是可能發生的。這四種語句以什麼楊的順訊交錯,才可以解釋這個結果呢?
在單個goroutine中,可以保證每個語句的作用/效果【effect】按執行順序發生;也就是說,goroutines是序列一致【 sequentially consistent】的。但是,如果沒有使用Channel或互斥鎖的方式進行顯式同步,就不能保證所有goroutines以相同的順序看到事件。雖然A goroutine在讀取y值之前必然會觀察到 x = 1這個寫入操作的效果,但它並不一定觀察到B goroutine對y的寫入作用效果,所以A可能列印處一個陳舊的y值。
儘管很容易把併發簡單的理解為多個goroutine中語句的某種交錯執行方式,但是正如上面的例子所示,這並不是現代編譯器和CPU的工作方式。因為複製語句和Print呼叫都使用了相同的變數,所以編譯器就可能會認為兩個語句的執行順序不會影響結果,然後就叫喚了兩個語句的執行順序。如果這兩個goroutine執行在不同的CPUs上,而每一個CPUs都有自己私有的緩衝區,那麼一個goroutine的寫入才做在同步到記憶體之前,對其他goroutine上的Print語句是不可見的。
通過一致地使用簡單的、成熟的模式,可以避免所有這些併發問題。在可能的情況下,將變數限制到單個goroutine中;對於所有其他變數,使用互斥。
9.5 延遲初始化 sync.Once
將昂貴的初始化步驟推遲到需要的時候,這是一個很好的實踐。預初始化變數會增加程式的啟動延遲,如果使用該變數的程式部分總不會被執行,那麼就沒有必要這樣做。讓我們回到我們在前面章節中看到的icons變數:
var icons map[string]image.Image
下面是懶載入版本的初始化:
func loadIcons() {
icons = map[string]image.Image{
"spades.png": loadIcon("spades.png"),
"hearts.png": loadIcon("hearts.png"),
"diamonds.png": loadIcon("diamonds.png"),
"clubs.png": loadIcon("clubs.png"),
}
}
// NOTE: not concurrency-safe!
func Icon(name string) image.Image {
if icons == nil {
loadIcons() // one-time initialization
}
return icons[name]
}
對那那些只被單執行緒訪問的變數,我們可以安全的使用上面的模式,但是如果Icon函式被併發呼叫,那麼怎麼使用則是不安全的。像銀行系統中原始的Deposit函式一樣,Icon函式也是由許多步驟組成的:它首先測試icons變數是否為nil,如果是nil,則健在所有的圖示,然後更新icons變數為一個非空值。直覺可能會認為,上述競態條件導致的最糟糕的結果可能是多次呼叫loadIcons函式。當第一個goroutine正忙於載入圖示時,另一個進入Icon函式的goroutine會發現變數仍然等於nil,並且還會呼叫loadIcons進行圖示載入。
但這種直覺也是錯誤的。(我們希望現在您正在培養一種關於併發性的新直覺,即關於併發性的直覺是不可信的!)回憶一下章節9.4中關於記憶體的討論。在缺乏顯式同步的情況下,編譯器和CPU在能保證每個goroutine都滿足序列一致性的基礎上,可以自由地重排對記憶體的訪問順序。下面顯示了對loadIcons語句的一種可能的重新排序。在填充它之前,它將空map儲存到icons變數中:
func loadIcons() {
icons = make(map[string]image.Image)
icons["spades.png"] = loadIcon("spades.png")
icons["hearts.png"] = loadIcon("hearts.png")
icons["diamonds.png"] = loadIcon("diamonds.png")
icons["clubs.png"] = loadIcon("clubs.png")
}
因此,一個goroutine發現icons為非nil,並不意味著變數的初始化已經完成。
確保所有的goroutine都可以觀察到loadIcons的效果的正確方式,是使用互斥來進行同步:
var mu sync.Mutex // guards icons
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {
mu.Lock()
defer mu.Unlock()
if icons == nil {
loadIcons()
}
return icons[name]
}
但是,強制對icons進行互斥訪問的代價是,兩個goroutines不能併發地訪問變數,即使變數已經安全地初始化了,並且永遠不會再被修改。這意味著我們可以使用一個多讀單寫鎖來優化:
var mu sync.RWMutex // guards icons
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {
mu.RLock()
if icons != nil {
icon := icons[name]
mu.RUnlock()
return icon
}
mu.RUnlock()
// acquire an exclusive lock
mu.Lock()
if icons == nil { // NOTE: must recheck for nil
loadIcons()
}
icon := icons[name]
mu.Unlock()
return icon
}
這裡有兩個臨界區。goroutine首先獲得一個讀鎖,檢索map,然後釋放鎖。如果從該map中檢索到一個條目(常見情況),就返回。如果沒有找到條目,goroutine就會獲得一個獨佔寫鎖。如果不首先釋放共享鎖,就無法將共享鎖升級到獨佔鎖,因此我們必須重新檢查icons變數,以防其他的goroutine已經初始化了它。
上面的模式帶給我們更好的併發性,但是也引入了複雜度,因此更容易出錯。幸運的是,sync包為一次性初始化問題提供專門的解決方案: sync.Once。從概念上講,Once由互斥量和布林變數組成,布林變數用於記錄是否進行了初始化;互斥變數則同時保護布林結構和客戶端資料結構。Once的唯一的方法Do接收初始化函式作為它的引數。讓我們簡化Icon函式:
var loadIconsOnce sync.Once
var icons map[string]image.Image
// Concurrency-safe.
func Icon(name string) image.Image {
loadIconsOnce.Do(loadIcons)
return icons[name]
}
每次呼叫 Do(loadIcons)時候都會先鎖定互斥量,並檢查布林變數。在第一次呼叫時。這個值為false,Do會先呼叫loadIncon然後將布林變數置為true。後續的呼叫相當於空呼叫
後續調的用什麼也不做,但是互斥同步器使得loadIcons函式對記憶體(在這裡就是icons)的作用效果變得對所有goroutine可見。通過使用sync.Once來同步,我們可以避免與其他goroutines共享變數,直到它們被正確構造。
9.6 競態檢測器(Race Detector)
簡單講- race 加到 go build , go run ,或者 go test命令上即可開啟檢測器
9.8 Goroutine和執行緒
在上一章中,我們說過可以先忽略goroutines和作業系統(OS)執行緒之間的區別。雖然它們之間的差異本質上是量變的,但一個足夠大的量變上會變成了質變,goroutines和threads也是如此。現在是時候區分它們了。
9.8.1 Growable Stacks
每一個OS執行緒都有一個固定大小的記憶體塊(通常帶下是2M)用於它的棧,在該工作區域中可以儲存處理中的函式呼叫或者臨時暫停的函式(呼叫了另一個函式)中的本地變數。這固定大小的棧有時就太大了,有時又太小了。對於一個小的goroutine,2M大小的的棧記憶體就是很浪費了,比如一個goroutine等待WaitGroup,然後關閉Channel。在Go中一次建立十萬左右的goroutine也不罕見,對於這種情況,棧就顯得太大了。另外,對於複雜的深度遞迴函式,固定大小的棧就顯得捉襟見肘了。改變棧的大小固定,可以提高空間效率並允許建立更多的執行緒,或者它可以支援更深層的遞迴函式,但它不能同時做到這兩點。
相比之下,goroutine以一個小的堆疊(通常為2KB)開始它的宣告週期。goroutine的堆疊,就像作業系統執行緒的堆疊一樣,持有當前活動函式呼叫和掛起函式呼叫的本地變數,但與作業系統執行緒不同的是,goroutine的堆疊不是固定的;它根據需要增長和收縮。goroutine堆疊的大小限制可能高達1GB,比典型的固定大小的執行緒堆疊大幾個數量級,當然,很少有goroutine使用這麼多。
9.8.2 Goroutine排程
OS執行緒由OS核心排程。每隔幾毫秒,一個硬體時鐘就會中斷處理器,而這會引發一個稱為scheduler的核心函式被呼叫。該函式會暫停當前執行的執行緒,並將它的暫存器資訊儲存到記憶體中,檢查執行緒列表,並決定選擇哪一個執行緒接下來執行,接下來會從記憶體中載入該執行緒的暫存器資訊,然後喚醒該執行緒執行。因為OS執行緒是由核心排程的,所以控制權從一個執行緒傳遞到另一個執行緒的整個過程被稱為上下文切換(context switch),簡而言之,就是儲存一個執行緒的狀態資訊進記憶體,恢復另個執行緒的狀態,然後更新排程器的資料結構。考慮到這個歌操作涉及到記憶體侷限性以及涉及的記憶體訪問數量,這種操作非常緩慢,而且隨著訪問記憶體所需的CPU週期數量的增加,這種操作只會變得更糟。
Go Runtime包含一個自己的排程器,它使用稱為m:n排程的技術,因為它可以複用m個goroutine到n個OS執行緒上。Go排程器與OS排程器類似,但是Go的排程器只需要關心單個Go程式中的goroutines即可。
與作業系統的執行緒排程器不同,Go的排程器並不是由硬體時鐘來觸發的,而是由一個特定的Go語言結構控制的。例如,當一個Goroutine呼叫time.Sleep或者阻塞在Channel或互斥操作上時,排程器會將這個goroutine置為休眠(sleep),並執行其他的goroutine,直到前一個可喚醒為止。因為不需要核心上下文切換,排程一個goroutine,也要比執行緒排程來的更廉價。
9.8.3. GOMAXPROCS
Go使用一個稱為GOMAXPROCS的引數,來決定使用多少個OS執行緒來同時執行Go程式碼。預設值是機器的CPU核數。因此在一個有8 CPUs的機器上,排程器會將Go程式碼同時排程到8個OS執行緒上執行( GOMAXPROCS是m:n中的n)。休眠(Sleeping)或者在通訊中阻塞的goroutine並不需要佔用一個執行緒。阻塞在I/O或者其他系統呼叫中的goroutine,或者是呼叫非Go編寫的函式的goroutine,這樣的Goroutines需要一個單獨的OS執行緒,但是這個執行緒並不在GOMAXPROCS中。
我們可以使用GOMAXPROCS環境變數來顯式的控制這個引數。下面這個小程式展示了GOMAXPROCS的作用效果,該程式會列印0和1的流資料。
for {
go fmt.Print(0)
fmt.Print(1)
}
$ GOMAXPROCS=1 go run hacker-cliché.go
111111111111111111110000000000000000000011111...
$ GOMAXPROCS=2 go run hacker-cliché.go
010101010101010101011001100101011010010100110...
在第一次執行中,沒最多有一個goroutine在執行。最初,執行的是main goroutine,它列印1。一段時間後,Go排程器讓它進入休眠狀態,並喚醒列印0的goroutine,讓它在OS執行緒上執行。在第二次執行中,有兩個作業系統執行緒可用,因此兩個goroutines可以同時執行,以相同的速度列印數字。我們必須強調,許多因素都影響到goroutine排程,執行時是不斷演進的,所以您的結果可能與上面的結果不同。
9.8.4. Goroutines Have No Identity
在大部分的作業系統和支援執行緒的程式語言中,當前執行緒有一個可區別的標識,它通常可取一個數值或者指標。這使得我們可以很輕鬆的實現一個稱謂執行緒本地儲存【Thread-Local Storage 】的抽象,它本質上是一個全域性的map,以執行緒的標識作為key,這樣每個執行緒都可以獨立的使用這個map讀取和儲存值,而不受其他執行緒的影響。
goroutine沒有可供程式設計師訪問的標識。這是由於涉及而決定的,因為執行緒本地儲存有被濫用的傾向。例如,在使用執行緒本地儲存實現的web伺服器中,許多函式通過查詢該儲存來查詢關於HTTP請求的資訊是很常見的。但就像那些過度依賴於全域性變數的程式一樣,這會導致一種不健康的“超距行為”,即函式的行為並不由引數決定,而是由返回的執行緒的標識。因此,如果執行緒的標識需要可改----比如需要使用工作執行緒【worker thread】來幫忙----這些函式的行為就會變得詭祕。
Go鼓勵一種更簡單的程式設計風格,其中能影響函式行為的引數必須是顯式的。這不僅使程式更易於閱讀,而且讓我們可以自由地將給定函式的子任務分配給多個不同的goroutines,而不必擔心這些goroutine的標識。。