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程序設計語言》內存同步這一節看一下。

 

  

 

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