1. 程式人生 > 實用技巧 >Go -- 併發程式設計

Go -- 併發程式設計

  主語言轉成Go了,記錄一些Go的學習筆記與心得,可能有點凌亂。內容來自於《Go程式設計語言》,這本書強烈推薦。

(Go中併發程式設計是使用的Go獨有的goroutine,不能完全等同於執行緒,但這不是這篇的重點,下面不做區分了)

  在序列程式中,程式中各個步驟的執行順序由程式邏輯決定。比如,在一系列語句中,第一句在第二句之前執行,以此類推。當一個程式中有多個goroutine時,每個goroutine內部的各個步驟也是按順序執行的,但我們不能確定一個goroutine中的事件x與另一個goroutine中的事件y的先後順序。如果我們無法自信地說一個事件肯定先於另外一個事件,那麼這兩個事件就是併發的。

(嗯,換了個角度理解併發,這個定義也確實有道理.

  關於併發程式設計會產生的問題,想必諸位都很清楚。諸如不同的執行緒操作相同的資料,造成的資料丟失,不一致,更新失效等等。在Go中關於併發產生的問題,重點可以討論一下“競態”----在多個goroutine按某些交錯順序執行時程式無法給出正確的結果。競態對於程式是致命的,因為它們可能會潛伏在程式中,出現頻率很低,很可能僅在高負載環境或者在使用特定的編譯器,平臺和架構時才出現。這些都使得競態很難再現和分析。

  資料競態發生於兩個goroutine併發讀寫同一個變數並且至少其中一個是寫入時。從定義出發,我們有幾種方法可以規避資料競態。

  第一種方法--不要修改變數(有點幽默,但也有效。每個執行緒都不會去寫資料,自然也不會發生資料競態的問題

  第二種方法--避免競態的方法是避免從多個goroutine訪問同一個變數.即我們只允許唯一的一個goroutine訪問共享的資源,無論有多少個goroutine在做別的操作,當他們需要更改訪問共享資源時都要使用同一個goroutine來實現,而共享的資源也被限制在了這個唯一的goroutine內,自然也就不會產生資料競態的問題。這也是Go這門語言的思想之一 ---- 不要通過共享記憶體來通訊,要通過通訊來共享記憶體.Go中可以用chan來實現這種方式.(關於Chan可以看看筆者前面的部落格喲

var deposits = make(chan int) //傳送存款餘額
var balances = make(chan int) //接收餘額

func Deposit(amount int) {deposits <- amount}
func Balance() int {return  <- balances}

func teller() {
    var balance int // balance被限制在 teller goroutine 中
    for {
        select {
        case amount := <-deposits:
            balance += amount
        case balances <- balance:
        }
    }
}

func init() {
    go teller()
}

  這個簡單的關於銀行的例子,可以看出我們把餘額balance限制在了teller內部,無論是更新餘額還是讀取當前餘額,都只能通過teller來實現,因此避免了競態的問題.

  這種方式還可以拓展,即使一個變數無法在整個生命週期受限於當個goroutine,加以限制仍然可以是解決併發訪問的好方法。比如一個常見的場景,可以通過藉助通道來把共享變數的地址從上一步傳到下一步,從而在流水線上的多個goroutine之間共享該變數。在流水線中的每一步,在把變數地址傳給下一步後就不再訪問該變量了,這樣所有對於這個變數的訪問都是序列的。這中方式有時也被稱為“序列受限”. 程式碼示例如下

type Cake struct {state string}

func baker(cooked chan <- *Cake) {
    for {
        cake := new(Cake)
        cake.state = "cooked"
        cooked <- cake // baker不再訪問cake變數
    }
}

func icer(iced chan<- *Cake, cooked <-chan *Cake) {
    for cake := range cooked {
        cake.state = "iced"
        iced <- cake // icer不再訪問cake變數
    }
}

  第三種避免資料競態的辦法是允許多個goroutine訪問同一個變數,但在同一時間內只有一個goroutine可以訪問。這種方法稱為互斥機制。通俗的說,這也就是我們常在別的地方使用的“鎖”。

  Go中的互斥鎖是由 sync.Mutex提供的。它提供了兩個方法Lock用於上鎖,Unlock用於解鎖。一個goroutine在每次訪問共享變數之前,它都必須先呼叫互斥量的Lock方法來獲取一個互斥鎖,如果其他的goroutine已經取走了互斥鎖,那麼操作會一直阻塞到其他goroutine呼叫Unlock之後。互斥變數保護共享變數。按照慣例,被互斥變數保護的變數宣告應當緊接在互斥變數的宣告之後。如果實際情況不是如此,請確認已加了註釋來說明此事.(深有同感,這確實是一個好的程式設計習慣)

  加鎖與解鎖應當成對的出現,特別是當一個方法有不同的分支,請確保每個分支結束時都釋放了鎖。(這點對於Go來說是特別的,一方面,Go語言的思想倡導儘快返回,一旦有錯誤就儘快返回,儘快的recover, 這就導致了一個方法中可能會有多個分支都返回。另一方面,由於defer方法,使我們不必在每個返回分支末尾都添上解鎖或釋放資源等操作,只要統一在defer中處理即可。)針對於互斥鎖,結合我們前面的銀行的例子的那部分的程式碼,我們來看一個有意思的問題。

//注意,這裡不是原子操作
func withdraw(amount int) bool {
    Deposit(-amount)
    if Balance() < 0 {
        Deposit(amount)
        return false
    }
    return  true
}

  邏輯很簡單,我們嘗試提現。如果提現後餘額小於0,則恢復餘額,並返回false,否則返回true. 當我們給Deposit與Balance的內部都加上鎖,來保證互斥訪問的時候,會有一個有意思的問題.首先要說明的是,這個方法是針對它本身的邏輯----能否提現成功,總是可以正確的返回的。但副作用時,在進行超額提現時,在Deposit與Balance之間,餘額是會降低到0以下的。換成實際一點的情況就是,你和你媳婦的共享的銀行卡里有10w,你嘗試買一輛法拉利時,導致了你媳婦買一杯咖啡付款失敗了,並且失敗原因是--餘額不足。這種情況的根源是,Deposit與Balance兩個方法內的鎖是割裂開的,並不是一個原子操作,也就是說,給了別的goroutine的可乘之機。雖然最終餘額方面的資料總是對的,但過程中也會發送諸如此類的錯誤。那如果我們用這樣的實現呢:

//注意,這裡是錯誤的實現
func withdraw(amount int) bool {
    mu.Lock()
    defer mu.Unlock()
    Deposit(-amount)
    if Balance() < 0 {
        Deposit(amount)
        return false
    }
    return  true
}

  即嘗試給withdraw本身加鎖。當然實際上,這是行不通的。由於Deposit內部也在加鎖,這樣的寫法最終會導致死鎖。一種改良方式是,分別實現包內可訪問的deposit方法(在呼叫處外部提供鎖,自己本身無鎖),以及包外可以訪問的Deposit(自己本身提供了互斥鎖), 這樣,在諸如提現這種需要同時使用更新餘額/查餘額的地方,我們就可以使用deposit來處理,並在提現方法本身提供鎖來保證原子性。

  當然,Go也支援讀寫鎖 sync.RWMutex. 關於讀寫鎖就不多bb了,但有一點要注意,只有在大部分goroutine都在獲取讀鎖,並且鎖競爭很激烈時,RWMutex才有優勢,因為RWMutex需要更加複雜的內部記錄工作,所以在競爭不激烈時它比普通的互斥鎖要慢。

  另外,書中提到由於現代計算機本身的多核機制以及Go中協程的實現,導致在一些無鎖的情況下(且兩個goroutine在不同的CPU上執行,每個CPU都有自己的快取),可能導致goroutine拿不到最新的值。不過這種方式一來比較極端,二來可以通過簡單且成熟的模式來避免。----在可能的情況下,把變數限制在單個goroutine內,對於其他的變數,採用互斥鎖。 對於這部分感興趣的同學,可以去搜一下Go的記憶體同步,或者直接找《Go程式設計語言》記憶體同步這一節看一下。