主語言轉成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程式設計語言》記憶體同步這一節看一下,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/90803.html
標籤:Go
