前言
在我前面一篇文章Golang受歡迎的原因中已經提到,Golang是在語言層面(runtime)就支援了併發模型。那麼作為程式設計人員,我們在實踐Golang的併發程式設計時,又有什麼需要注意的點呢?下面我會跟大家詳細的介紹一些在實際生產程式設計中很容易踩坑的知識點。
CSP
在介紹Golang的併發實踐前,有必要先介紹簡單介紹一下CSP理論。CSP,全稱是Communicating sequential processes,翻譯為通訊順序程式,又翻譯為交換訊息的順序程式,用來描述併發性系統的互動模式。CSP有以下三個特點:
1.每個程式是為了順序執行而建立的
2.資料通過管道來通訊,而不是通過共享記憶體
3.通過增加相同的程式來擴容
Golang的併發模型基於CSP理論,Golang併發的口號是:不用通過共享記憶體來通訊,而是通過通訊來共享記憶體。
Golang併發模式
Golang用來支援併發的元素集:
- goroutines
- channels
- select
- sync package
其中goroutines,channels和select 對應於實現CSP理論,即通過通訊來共享記憶體。這幾乎能解決Golang併發的90%問題,另外的10%場景需要通過同步原語來解決,即sync包相關的結構。
看圖識channel
如上圖所示,我們從一個簡單的沙桶傳遞小遊戲來認識Golang中的channel。其中藍色的Gopher為傳送方,紫色的Gopher為接受方,中間的灰色Gopher代表channel的緩衝區大小。
channel介紹
阻塞channel
不帶buffer的channel阻塞情況:
unbuffered := make(chan int) a := <- unbuffered // 阻塞 unbuffered := make(chan int) // 1) 阻塞 a := <- unbuffered // 2) 阻塞 unbuffered <- 1 // 3) 同步 go func() { <-unbuffered }() unbuffered <- 1
帶buffer的channel阻塞情況:
buffered := make(chan int, 1) // 4) 阻塞 a := <- buffered // 5) 不阻塞 buffered <-1 // 6) buffer滿,阻塞 buffered <-2
上述情況其實歸納起來很簡單:不管有無緩衝區channel,寫滿或者讀空都會阻塞。
不帶buffer和帶buffer的channel用途:
- 不帶buffer的channel:用於同步通訊。
- 帶buffer的channel:用於非同步通訊。
關閉channel
c := make(chan int) close(c) fmt.Println(<-c) //接收並輸出chan型別的零值,這裡int是0
需要特殊說明的是,channel不像socket或者檔案,不需要通過close來釋放資源。需要close的唯一情況是,通過close觸發channel讀事件,comma,ok := <- c 中ok為false,表示channel已經關閉。只能在傳送端close channel,因為channel關閉接收端能感知到,但是傳送端感知不到,只能主動關閉。往已經關閉的channel傳送資訊將會觸發panic。
select
類似switch語句,只不過case都是channel的讀或者寫操作,也可能是default。case的順序一點都不重要,不要依賴case的先後來定義優先順序,第一個非阻塞(send and/or receive)的case將會被選中。
使channel不阻塞
func TryReceive(c <-chan int) (data int, more, ok bool) { select { case data, more = <- c: return data, more, true } default: return 0, true, false }
當select中的case都處於阻塞狀態時,就會選中default分支。
或者超時返回:
func TryReceiveWithTimeout(c <-chan int, duration time.Duration) (data int, more, ok bool) { select { case data, more = <-c: return data, more, true case <- time.After(duration): return 0, true, false } }
time.After(duration)會返回一個channel,當duration到期時會觸發channel的讀事件。
Channel的缺點:
1.Channel可能會導致死鎖(迴圈阻塞)
2.channel中傳遞的都是資料的拷貝,可能會影響效能
3.channel中傳遞指標會導致資料競態問題(data race/ race conditions)
第三點中提到了資料競態問題,也就是通常所說data race。在接著往下講之前有必要先簡單講解下data race的危害。data race 指的是多執行緒併發讀寫一個變數,對應到Golang中就是多個goroutine同時讀寫一個變數,這種行為是未定義的,也就是說讀變數出來的值很有可能不是寫入的值,這個值是任意值都有可能。
例如下面這段程式碼:
package main import ( "fmt" "runtime" "time" ) var i int64 = 0 func main() { runtime.GOMAXPROCS(2) go func() { for { fmt.Println("i is", i) time.Sleep(time.Second) } }() for { i += 1 } }
在我mac本地環境會不斷的輸出0。全域性變數i被兩個goroutine同時讀寫,也就是我們所說的data race,導致了i的值是未定義的。如果讀寫的是一塊動態伸縮的記憶體,很有可能會導致panic。例如多goroutine讀寫map。幸運的是,Golang針對data race有專門的內建工具,例如把上面的程式碼儲存為main.go,執行 go run -race main.go 會把相關的data race輸出:
================== WARNING: DATA RACE Read at 0x00000121e848 by goroutine 6: main.main.func1() /Users/saas/src/awesomeProject/datarace/main.go:15 +0x3e Previous write at 0x00000121e848 by main goroutine: main.main() /Users/saas/src/awesomeProject/datarace/main.go:21 +0x7b Goroutine 6 (running) created at: main.main() /Users/saas/src/awesomeProject/datarace/main.go:13 +0x4f ==================
上面提到了一些channel的缺點,文章一開始我也提到了channel能解決Golang併發程式設計的90%問題,那剩下的一些少數併發情況用什麼更優的方案呢?
鎖會不會是個更優的解決方案呢?
鎖就像廁所的坑位一樣,你佔用的時間越長,等待的人排的隊就會越長。讀寫鎖只會減緩這種情況。另外使用多個鎖很容易導致死鎖。總而言之,鎖不是我們只在尋找的方案。
原子操作
原子操作是這10%場景有限考慮的解決方案。原子操作是在CPU層面保證了原子性。不用程式設計人員加鎖。Golang對應的操作在sync.atomic 包。Store, Load, Add, Swap 和 CompareAndSwap方法。
CompareAndSwap 方法
type Spinlock struct { state *int32 } const free = int32(0) func (l *Spinlock) Lock() { for !atomic.CompareAndSwapInt32(l.state, free, 42) { //如果state等於0就賦值為42 runtime.Gosched() //讓出CPU } } func (l *Spinlock) Unlock(){ atomic.StoreInt32(l.state, free) // 所有操作state變數的操作都應該是原子的 }
基於上面的一些併發實踐的建議是:
1.避免阻塞,避免資料競態
2.用channel避免共享記憶體,用select管理channel
3.當channel不適用於你的場景時,儘量用sync包的原子操作,如果實在需要用到鎖,儘量縮小鎖的粒度(鎖住儘量少的程式碼)。
併發程式找錯
根據前面介紹的內容,我們來看看下面的這個例子有沒有什麼問題:
func restore(repos []string) error { errChan := make(chan error, 1) sem := make(chan int, 4) // four jobs at once var wg sync.WaitGroup wg.Add(len(repos)) for _, repo := range repos { sem <- 1 go func() { defer func() { wg.Done() <- sem }() if err := fetch(repo); err != nil { errChan <- err } }() } wg.Wait() close(sem) close(errChan) return <- errChan }
Bug1. sem無需關閉
Bug2.go和匿名函式觸發的bug,repo不斷在更新,fetch拿到的repo是未定義的。有data race問題。
Bug3.sem<-1放在go func外面啟動同時有4個goroutine在執行,並不能很好的控制同時有4個fetch任務。
Bug4. errChan的緩衝區大小為1,當多個fetch產生err時,將會導致程式死鎖。
改良後的程式:
func restore(repos []string) error { errChan := make(chan error, 1) sem := make(chan int, 4) // four jobs at once var wg sync.WaitGroup wg.Add(len(repos)) for _, repo := range repos { go worker(repo, sem, &wg, errChan) } wg.Wait() close(errChan) return <- errChan } Func worker(repo string, sem chan int, wg *sync.WaitGroup, errChan chan err) { defer wg.Done() sem <- 1 if err := fetch(repo); err != nil { select { case errChan <- err: // we are the first worker to fail default: // some other failure has already happened, drop this one } } <- sem }
最後思考:為什麼errChan一定要close?
因為最後的return<-errChan,如果fetch的err都為nil,那麼errChan就是空,<-errChan是個永久阻塞的操作,close(sem)會觸發讀事件,返回chan累心的零值,這裡是nil。
基於上面的一些併發實踐的建議是:
1.channel不是socket和file這種資源,不需要通過close來釋放資源
2.避免將goroutine和匿名函式一起使用
3.在你啟動一個goroutine之前,一定要清楚它會在什麼時候,什麼情況下會退出。
總結
本文介紹了Golang併發程式設計的一些高效實踐建議,旨在讓大家在Golang併發實踐中少踩坑。其中data race問題和goroutine退出的時機尤為重要。
參考
https://www.youtube.com/watch?v=YEKjSzIwAdA
https://www.youtube.com/watch?v=yKQOunhhf4A
https://www.youtube.com/watch?v=QDDwwePbDtw
https://ms2008.github.io/2019/05/12/golang-data-race/