gopl-zh.github.com/ch9/ch9-02.md

7.6 KiB
Raw Blame History

9.2. sync.Mutex互斥鎖

在8.6節中我們使用了一個buffered channel作爲一個計數信號量來保證最多隻有20個goroutine會同時執行HTTP請求。同理我們可以用一個容量隻有1的channel來保證最多隻有一個goroutine在同一時刻訪問一個共享變量。一個隻能爲1和0的信號量叫做二元信號量(binary semaphore)。

gopl.io/ch9/bank2

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(這里叫鎖)併且Unlock方法會釋放這個token

gopl.io/ch9/bank3

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訪問bank變量時(這里隻有balance餘額變量)它都會調用mutex的Lock方法來獲取一個互斥鎖。如果其它的goroutine已經獲得了這個鎖的話這個操作會被阻塞直到其它goroutine調用了Unlock使該鎖變迴可用狀態。mutex會保護共享變量。慣例來説被mutex所保護的變量是在mutex變量聲明之後立刻聲明的。如果你的做法和慣例不符確保在文檔里對你的做法進行説明。

在Lock和Unlock之間的代碼段中的內容goroutine可以隨便讀取或者脩改這個代碼段叫做臨界區。goroutine在結束後釋放鎖是必要的無論以哪條路徑通過函數都需要釋放卽使是在錯誤路徑中也要記得釋放。

上面的bank程序例證了一種通用的併發模式。一繫列的導出函數封裝了一個或多個變量那麽訪問這些變量唯一的方式就是通過這些函數來做(或者方法,對於一個對象的變量來説)。每一個函數在一開始就獲取互斥鎖併在最後釋放鎖從而保證共享變量不會被併發訪問。這種函數、互斥鎖和變量的編排叫作監控monitor(這種老式單詞的monitor是受"monitor goroutine"的術語啟發而來的。兩種用法都是一個代理人保證變量被順序訪問)。

由於在存款和査詢餘額函數中的臨界區代碼這麽短--隻有一行,沒有分支調用--在代碼最後去調用Unlock就顯得更爲直截了當。在更複雜的臨界區的應用中尤其是必須要盡早處理錯誤併返迴的情況下就很難去(靠人)判斷對Lock和Unlock的調用是在所有路徑中都能夠嚴格配對的了。Go語言里的defer簡直就是這種情況下的救星我們用defer來調用Unlock臨界區會隱式地延伸到函數作用域的最後這樣我們就從“總要記得在函數返迴之後或者發生錯誤返迴時要記得調用一次Unlock”這種狀態中獲得了解放。Go會自動幫我們完成這些事情。

func Balance() int {
	mu.Lock()
	defer mu.Unlock()
	return balance
}

上面的例子里Unlock會在return語句讀取完balance的值之後執行所以Balance函數是併發安全的。這帶來的另一點好處是我們再也不需要一個本地變量b了。

此外一個deferred Unlock卽使在臨界區發生panic時依然會執行這對於用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
}

函數終於給出了正確的結果但是還有一點討厭的副作用。當過多的取款操作同時執行時balance可能會瞬時被減到0以下。這可能會引起一個併發的取款被不合邏輯地拒絶。所以如果Bob嚐試買一輛sports car時Alice可能就沒辦法爲她的早咖啡付款了。這里的問題是取款不是一個原子操作它包含了三個步驟每一步都需要去獲取併釋放互斥鎖但任何一次鎖都不會鎖上整個取款流程。

理想情況下,取款應該隻在整個操作中獲得一次互斥鎖。下面這樣的嚐試是錯誤的:

// 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()第二次去獲取互斥鎖但因爲mutex已經鎖上了而無法被重入(譯註go里沒有重入鎖關於重入鎖的概念請參考java)--也就是説沒法對一個已經鎖上的mutex來再次上鎖--這會導致程序死鎖沒法繼續執行下去Withdraw會永遠阻塞下去。

關於Go的互斥量不能重入這一點我們有很充分的理由。互斥量的目的是爲了確保共享變量在程序執行時的關鍵點上能夠保證不變性。不變性的其中之一是“沒有goroutine訪問共享變量”。但實際上對於mutex保護的變量來説不變性還包括其它方面。當一個goroutine獲得了一個互斥鎖時它會斷定這種不變性能夠被保持。其獲取併保持鎖期間可能會去更新共享變量這樣不變性隻是短暫地被破壞。然而當其釋放鎖之後它必須保證不變性已經恢複原樣。盡管一個可以重入的mutex也可以保證沒有其它的goroutine在訪問共享變量但這種方式沒法保證這些變量額外的不變性。(譯註:這段翻譯有點暈)

一個通用的解決方案是將一個函數分離爲多個函數比如我們把Deposit分離成兩個一個不導出的函數deposit這個函數假設鎖總是會被保持併去做實際的操作另一個是導出的函數Deposit這個函數會調用deposit但在調用前會先去獲取鎖。同理我們可以將Withdraw也表示成這種形式

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), 用限製一個程序中的意外交互的方式可以使我們獲得數據結構的不變性。因爲某種原因封裝還幫我們獲得了併發的不變性。當你使用mutex時確保mutex和其保護的變量沒有被導出(在go里也就是小寫且不要被大寫字母開頭的函數訪問啦)無論這些變量是包級的變量還是一個struct的字段。