《go語言聖經》筆記之--防止數據競爭的三種方式

第一種方法是不要去寫變量

如果我們在創建goroutine之前的初始化階段,就初始化了package級別變量並且再也不去修改它們,那麼任意數量的goroutine併發訪問I這個package級別變量都是安全的,因爲每一個goroutine都只是去讀取而已。

實例代碼如下:

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"),
}

// 該函數是併發安全的
func Icon(name string) image.Image { return icons[name] }

第二種方法是避免從多個goroutine訪問變量

變量被限定在了一個單獨的goroutine中,其它的goroutine不能夠直接訪問變量,它們只能使用一個channel來發送請求給指定的goroutine來查詢更新變量。這也就是Go的口頭禪“不要使用共享數據來通信;使用通信來共享數據”。一個提供對一個指定的變量通過channel來請求的goroutine叫做這個變量的monitor(監控)goroutine。

實例代碼如下:

//銀行存款與查看餘額的例子
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 }	 //查看餘額

//balance變量被限制在了monitor goroutine中,名爲teller
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,綁定依然是併發問題的一個解決方案。例如在一條流水線上的goroutine之間共享變量是很普遍的行爲,在這兩者間會通過channel來傳輸地址信息。如果流水線的每一個階段都能夠避免在將變量傳送到下一階段後再去訪問它,那麼對這個變量的所有訪問就是線性的。其效果是變量會被綁定到流水線的一個階段,傳送完之後被綁定到下一個,以此類推。這種規則有時被稱爲串行綁定

下面的例子中,Cakes會被嚴格地順序訪問,先是baker gorouine,然後是icer gorouine:

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
    }
}

第三種方法是互斥訪問

我們可以用一個容量只有1的channel來保證最多隻有一個goroutine在同一時刻訪問一個共享變量。一個只能爲1和0的信號量叫做二元信號量(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
}

我們也可以使用互斥鎖

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獲取該鎖之前需要調用Unlock。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來恢復的程序來說是很重要的。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的mutex不能重入這一點我們有很充分的理由。mutex的目的是確保共享變量在程序執行時的關鍵點上能夠保證不變性。不變性的其中之一是“沒有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函數不需要理會對它的調用,儘管如此,這裏的表達還是表明了規則。

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





發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章