競態
競態是指多個goroutine按某些交錯順序執行時無法給出正確的結果。競態對於程序是致命的,因為它們可能潛伏在程序中,出現的頻率也很低,有可能僅在高負載環境或者使用特定平台、架構時才出現。數據競態發生於兩個goroutine並發讀寫同一個變量並且至少其中一個是寫入時。當發生數據競態的變量類型是大於一個機器字長的類型(接口、字符串和slice)時,事情就變得復雜了。
var x []int go func(){ x = make([]int,10)}() go func(){ x = make([]int,1000)}() x[999]=1 //注意:未定義行為,可能造成內存異常
上述並發調用可能造成slice指針來自於第一個make調用而長度來自於第二個make調用,造成內存讀取異常。如何在程序中避免數據競態呢?
第一種方法是不要修改變量。那些從不修改的數據結構以及不可變數據結構本質上是並發安全的,也不需要做任何同步。
第二種是避免從多個goroutine訪問同一個變量。由於其他goroutine無法直接訪問相關變量,因此它們就必須使用通道來向受限goroutine發送查詢請求或者更新變量,“不要通過共享內存來通信,而應該通過通信來共享內存”。使用通道請求一個受限變量的所有訪問的goroutine稱為該變量的監控goroutine。
第三種避免數據競態的辦法是允許多個goroutine訪問同一個變量,但在同一時間只有一個goroutine可以訪問,這種方法稱為互斥機制。
互斥鎖:sync.Mutex
一個計數上限為1的信號量稱為二進制信號量。
var( sema = make(chan struct{},1) balance int ) func Deposit(amount int){ sema<- struct{}{} //獲取令牌 balance += amount <-sema //釋放令牌 } func Balance() int { sema<- struct{}{} //獲取令牌 b := balance <-sema return b }
sync包有一個單獨的Mutex類型來支持這種模式,它的Lock方法用於獲取令牌,Unlock用於釋放令牌。
var( mu = sync.Mutex{} //保護balance balance int ) func Deposit(amount int){ mu.Lock() //獲取令牌 balance += amount //臨界區 mu.Unlock() //釋放令牌 } func Balance() int { mu.Lock() //獲取令牌 b := balance mu.Unlock() return b }
鎖的概念這里不在介紹,只是演示一下在go語言里面怎么使用。這種函數、互斥鎖、變量的組合方式稱為監控模式。在更復雜的場景中,很難確定在所有分支中Lock和UnLock都成對執行了。Go語言的defer語句就可以解決這個問題:通過延遲執行Unlock就可以把臨界區域隱式擴展到當前函數結尾,避免了必須在一個或者多個遠離Lock的位置插入一條Unlock語句。
func Balance() int { mu.Lock() defer mu.Unlock() return balance //不再需要局部變量b了 }
當然defer執行成本大,但在處理並發程序時,優先考慮清晰度,拒絕過早優化。在可以使用的地方,盡量使用defer來讓臨界區域擴展到函數結尾處。
讀寫互斥鎖:sync.RWMutex
讀操作是完全可以並發運行的,但寫操作需要獲得完全獨享的訪問權限,這種鎖稱為多讀單寫鎖,go語言通過sync.RWMutex提供。
var mu sync.RWMutex var balance int func Balance() int{ mu.RLock() //讀鎖 defer mu.RUnlock() return balance }
Deposite函數無須更改,通過調用mu.Lock和mu.Unlock獲取和釋放一個寫鎖。
僅在絕大部分goroutine都在獲取讀鎖並且鎖競爭比較激烈時,RWMutex才有優勢。
內存同步
Balance方法需要互斥鎖的原因有兩個:防止Balance插入到其他操作中間也是很重要的;同步不僅涉及多個goroutine的執行順序問題,還會影響到內存。考慮下面程序:
var x,y int go func(){ x = 1 fmt.Print("y:",y,"") }() go func(){ y=1 fmt.Print("x:",x,"") }()
y:0 x:1/x:0 y:1都有可能出現,但是x:0 y:0/y:0 x:0的出現就在我們意料之外了,在某些特定編譯器、CPU下這的確可能發生。
盡管很容易把並發理解為多個goroutine中語句的某種交錯執行方式,但正如上面例子所顯示,這並不是一個現代編譯器和CPU的工作方式。因為賦值和Print對應不同的變量,所以編譯器就可能會認為兩個語句的執行順序不會影響結果,然后就交換了這兩個語句的執行順序。CPU也有類似問題,如果兩個goroutine在不同的CPU上執行,每個CPU有自己的緩存,那么一個goroutine的寫入操作在同步到內存之前對另一個goroutine的Print語句是不可見的。
總結:在可能的情況下,把變量限制到單個goroutine中,對於其他變量,使用互斥鎖。因為在缺乏顯式同步的情況下,編譯器和CPU在能保證每個goroutine都滿足串行一致性的基礎上可以自由地重排訪問內存的順序。
延遲初始化:sync.Once
預先初始化一個變量會增加程序的啟動延時,並且如果實際執行時有可能根本用不上這個變量,那么初始化也不是必須的。
sync包提供了針對一次性初始化問題的特化解決方案:sync.Once。從概念上講,Once包含一個布爾變量和一個互斥變量,布爾變量記錄初始化是否已經完成,互斥量則負責保護這個布爾變量和客戶端的數據結構。
var loadIconsOnce sync.Once loadIconsOnce.Do(func_name) //Once唯一方法Do以初始化函數作為它的參數
競態檢測器
Go語言運行時和工具鏈裝備了一個精致並易於使用的動態分析工具:競態檢測器(race detector)。
簡單的把-race命令行參數加到go build、go run、go test命令里邊即可使用該功能。競態檢測器會研究時間流,找到那些有問題的案例。這個工具會輸出一份報告,包括變量的標識以及讀寫goroutine當時的調用棧,通常情況下這些信息足以定位問題了。
goroutine與線程
可增長的棧
每個OS線程都有一個固定大小的棧內存(通常是2MB),棧內存區域用於保存在其他函數調用期間那些正在執行或臨時暫停的函數中的局部變量。這個固定的棧大小既太大又太小。
作為對比,一個goroutine在生命周期開始時只有一個很小的棧,典型情況下時2KB,這個棧不是固定大小的,它可以按需增大或縮小。goroutine的棧大小限制可以達到1GB。
goroutine調度
OS線程由OS內核來調度,每隔幾毫秒,一個硬件時鍾中斷發送到CPU,CPU調用一個叫調度器的內核函數。以為OS線程由內核來調度,所以控制權限從一個線程到另外一個線程需要一個完整的上下文切換:即保存一個線程的狀態到內存,再恢復另外一個線程的狀態,最后更新調度器的數據結構,這個操作其實是很慢的。
Go運行時包含一個自己的調度器,這個調度器使用一個稱為m:n調度的技術(復用/調度m個goroutine到n個OS線程)。Go調度器只需關心單個Go程序的goroutine問題,它不是由硬件時鍾來定期觸發的,而是由特定的Go語言結構來觸發的。因為它不需要切換到內核語境,所以調度一個goroutine比調度一個線程成本低很多。
GOMAXPROCS
Go調度器使用GOMAXPROCS參數來確定需要使用多少個OS線程來同時執行Go代碼,默認是機器上CPU數量,一個有8個CPU的機器上,調度器會把Go代碼同時調度到8個OS線程上(GOMAXPROCS是m:n中的n)。正在休眠或者正被通道阻塞的goroutine不需要占用線程;阻塞在IO和其他系統調用中或調用非Go語言寫的函數的goroutine需要一個獨立的OS線程,但這個線程不計算在GOMAXPROCS內。
goroutine沒有標識
在大部分支持多線程的操作系統和編程語言里,當前線程都有一個獨特的標識,它通常可以取一個整數或者指針。goroutine沒有可供程序員訪問的標識,go語言鼓勵一種更簡單的編程風格,其中,能影響一個函數行為的參數應當是顯式指定的。