《Go 語言程式設計》讀書筆記 (六) 基於共享變數的併發

KevinYan發表於2020-01-04

競爭條件

  • 在一個線性(就是說只有一個goroutine的)的程式中,程式的執行順序只由程式的邏輯來決定。在有兩個或更多goroutine的程式中,每一個goroutine內的語句也是按照既定的順序去執行的,但是一般情況下我們沒法知道分別位於兩個goroutine的事件x和y的執行順序,x是在y之前?之後?還是同時發生?是沒法判斷的。當我們沒有辦法確認一個事件是在另一個事件的前面還是後面發生的話,就說明x和y這兩個事件是併發的。

  • 一個函式線上性程式中可以正確地工作。如果在併發的情況下,這個函式依然可以正確地工作的話,那麼我們就說這個函式是併發安全的,併發安全的函式不需要額外的同步工作。我們可以把這個概念概括為一個特定型別的一些方法和操作函式,如果這個型別是併發安全的話,那麼所有它的訪問方法和操作就都是併發安全的。

  • 競爭條件指的是程式在多個goroutine交叉執行操作時,沒有給出正確的結果。競爭條件是很惡劣的一種場景,因為這種問題會一直潛伏在你的程式裡,然後在非常少見的時候蹦出來,或許只是會在很大的負載時才會發生。

  • 無論任何時候,只要有兩個goroutine併發訪問同一變數,且至少其中的一個是寫操作的時候就會發生資料競爭。

  • 避免資料競爭的方法是允許很多goroutine去訪問變數,但是在同一個時刻最多隻有一個goroutine在訪問。這種方式被稱為“互斥”。

sync.Mutex互斥鎖

  • 我們可以用一個容量只有1的channel來保證最多隻有一個goroutine在同一時刻訪問一個共享變數。一個只能為1和0的訊號量叫做二元訊號量(binary semaphore)。
  • 下面用容量為 1 的 bufferred channel 實現互斥鎖
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:
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
}
  • 在Lock和Unlock之間的程式碼段在goroutine可以隨便讀取或者修改共享變數,這個程式碼段叫做臨界區。goroutine在結束後釋放鎖是必要的,無論以哪條路徑透過函式都需要釋放,即使是在錯誤路徑中,也要記得釋放。

  • 由於上面存款和查詢餘額函式中的臨界區程式碼這麼短–只有一行,沒有分支呼叫–在程式碼最後去呼叫Unlock就顯得更為直截了當。在更復雜的臨界區的應用中,尤其是必須要儘早處理錯誤並返回的情況下,就很難去(靠人)判斷對Lock和Unlock的呼叫是在所有路徑中都能夠嚴格配對的了。Go語言裡的defer簡直就是這種情況下的救星:我們用defer來呼叫Unlock,臨界區會隱式地延伸到函式作用域的最後,這樣我們就從“總要記得在函式返回之後或者發生錯誤返回時要記得呼叫一次Unlock”這種狀態中獲得瞭解放。Go會自動幫我們完成這些事情。

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

上面的例子裡Unlock會在return語句讀取完balance的值之後執行,所以Balance函式是併發安全的。

  • 一個deferred Unlock即使在臨界區發生panic時依然會執行,這對於用recover 來恢復的程式來說是很重要的。defer呼叫只會比顯式地呼叫Unlock成本高那麼一點點,不過卻在很大程度上保證了程式碼的整潔性。大多數情況下對於併發程式來說,程式碼的整潔性比過度的最佳化更重要。儘量使用defer來將臨界區擴充套件到函式的結束。

sync.RWMutex讀寫鎖

  • 由於Balance函式只需要讀取變數的狀態,所以我們同時讓多個Balance呼叫併發執行事實上是安全的,只要在執行的時候沒有存款或者取款操作就行。在這種場景下我們需要一種特殊型別的鎖,其允許多個只讀操作並行執行,但寫操作會完全互斥。這種鎖叫做“多讀單寫”鎖(multiple readers, single writer lock),Go語言提供的這樣的鎖是sync.RWMutex:
var mu sync.RWMutex
var balance int
func Balance() int {
    mu.RLock() // readers lock
    defer mu.RUnlock()
    return balance
}
  • Balance函式現在呼叫了RLock和RUnlock方法來獲取和釋放一個讀共享鎖。Deposit函式沒有變化,會呼叫mu.Lock和mu.Unlock方法來獲取和釋放一個寫互斥鎖。

  • RWMutex只有當獲得鎖的大部分goroutine都是讀操作,而且鎖是在競爭條件下,也就是說,goroutine們必須等待才能獲取到鎖的時候,RWMutex才是最能帶來好處的。RWMutex需要更復雜的內部記錄,所以會讓它的效能比一般的mutex鎖慢一些。

記憶體同步

你可能比較糾結為什麼Balance方法只由一個簡單的操作組成也需要用到互斥條件?這裡使用mutex有兩方面考慮。第一Balance不會在其它操作比如Withdraw“中間”執行。第二(更重要)的是”同步”不僅僅是一堆goroutine執行順序的問題;同樣也會涉及到記憶體的問題。

在現代計算機中可能會有一堆處理器,每一個都會有其本地快取(local cache)。為了效率,對記憶體的寫入一般會在每一個處理器中緩衝,並在必要時一起flush到主存。這種情況下這些資料可能會以與當初goroutine寫入順序不同的順序被提交到主存。像channel通訊或者互斥量操作這樣的原語會使處理器將其聚集的寫入flush並commit,這樣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

然而實際的執行時還是有些情況讓我們有點驚訝:

x:0 y:0
y:0 x:0

那麼這兩種情況要怎麼解釋呢?

在一個獨立的goroutine中,每一個語句的執行順序是可以被保證的;也就是說goroutine是順序連貫的。但是在不使用channel且不使用mutex這樣的顯式同步操作時,我們就沒法保證事件在不同的goroutine中看到的執行順序是一致的了。儘管goroutine A中一定需要觀察到x=1執行成功之後才會去讀取y,但它沒法確保自己觀察得到goroutine B中對y的寫入,所以A還可能會列印出y的一箇舊版的值。

儘管去理解併發的一種嘗試是去將其執行理解為不同goroutine語句的交錯執行,但看看上面的例子,這已經不是現代的編譯器和cpu的工作方式了。因為賦值和列印指向不同的變數,編譯器可能會斷定兩條語句的順序不會影響執行結果,並且會交換兩個語句的執行順序。如果兩個goroutine在不同的CPU上執行,每一個核心有自己的快取,這樣一個goroutine的寫入對於其它goroutine的Print,在主存同步之前就是不可見的了。

所有併發的問題都可以用一致的、簡單的既定的模式來規避。所以可能的話,將變數限定在goroutine內部;如果是多個goroutine都需要訪問的變數,使用互斥條件來訪問。

競爭條件檢測

只要在go build,go run或者go test命令後面加上-race的flag,就會使編譯器建立一個你的應用的“修改”版或者一個附帶了能夠記錄所有執行期對共享變數訪問工具的test,並且會記錄下每一個讀或者寫共享變數的goroutine的身份資訊。另外,修改版的程式會記錄下所有的同步事件,比如go語句,channel操作,以及對(sync.Mutex).Lock,(sync.WaitGroup).Wait等等的呼叫。

競爭檢查器會報告所有的已經發生的資料競爭。然而,它只能檢測到執行時的競爭條件;並不能證明之後不會發生資料競爭。所以為了使結果儘量正確,請保證你的測試併發地覆蓋到了你到包。

由於需要額外的記錄,因此構建時加了競爭檢測的程式跑起來會慢一些,且需要更大的記憶體,即使是這樣,這些代價對於很多生產環境的工作來說還是可以接受的。對於一些偶發的競爭條件來說,讓競爭檢查器來幹活可以節省無數日夜的debugging。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
公眾號:網管叨bi叨 | Golang、Laravel、Docker、K8s等學習經驗分享

相關文章