轉載自:http://www.woola.net/detail/2017-04-27-goroutines.html
本文是一篇並發編程方面的入門文章,以Go語言編寫範例程式碼,內容涵蓋:
運行期並發線程(goroutines)基本的同步技術(管道和鎖)Go語言中基本的併發模式死結和資料競爭並行計算
運行期線程
go 關鍵詞後面的語句會以一個新的線程去運行,至於這個線程與java那種線程有什麼區別我們後面說明
Go允許使用go語句開啟一個新的運行期線程, 即 goroutine,以一個不同的、新建立的goroutine來執行一個函數。 同一個程式中的所有goroutine共用同一個地址空間。
Goroutine非常輕量 ,除了為之分配的棧空間,其所佔用的記憶體空間微乎其微。 並且其棧空間在開始時非常小,之後隨著堆儲存空間的按需分配或釋放而變化。
內部實現上,goroutine會在多個作業系統線程上多工 (如果當前線程被堵塞會跳到其他線程去執行)。 如果一個goroutine阻塞了一個作業系統線程,
例如:等待輸入,這個線程上的其他goroutine就會遷移到其他線程,這樣能繼續運行。 開發人員並不需要關心/擔心這些細節。
下面所示程式會輸出“Hello from main goroutine”。 也可能會輸出“Hello from another goroutine”,具體依賴於兩個goroutine哪個先結束。
以下程式碼片段,不是完整, 在測試可能需要引入對應的包。
func main() { go fmt.Println("Hello from another goroutine”)// 線程 fmt.Println("Hello from main goroutine") // 至此,程式運行結束, // 所有活躍的goroutine被殺死}
接下來的這個程式,多數情況下, 會輸出“Hello from main goroutine” 和 “Hello from another goroutine”,輸出的順序不確定。
但還有另一個可能性是:第二個goroutine運行得極其慢, 在程式結束之前都沒來得及輸出相應的訊息。
func main() { go fmt.Println("Hello from another goroutine") fmt.Println("Hello from main goroutine") time.Sleep(time.Second) // 等待1秒,等另一個goroutine結束}
下面則是一個相對更加實際的樣本,其中定義了一個函數使用並發來延遲觸發一個事件。
// 函數Publish在給定時間到期後列印text字串到標準輸出// 該函數並不會阻塞而是立即返回func Publish(text string, delay time.Duration) { go func() { time.Sleep(delay) fmt.Println("BREAKING NEWS:", text) }() // 注意這裡的括弧。必須調用匿名函數}
你可能會這樣使用Publish函數:
func main() { Publish("A goroutine starts a new thread of execution.", 5*time.Second) fmt.Println("Let’s hope the news will published before I leave.") // 等待發布新聞 time.Sleep(10 * time.Second) fmt.Println("Ten seconds later: I’m leaving now.")}
這個程式,絕大多數情況下,會輸出以下三行,順序固定,每行輸出之間相隔5秒。
$ go run publish1.goLet’s hope the news will published before I leave.BREAKING NEWS: A goroutine starts a new thread of execution.Ten seconds later: I’m leaving now.
一般來說,通過睡眠的方式來編排線程之間相互等待是不太可能的。 下一章節會介紹Go語言中的一種同步機制 - 管道, 並示範如何使用管道讓一個goroutine等待另一個goroutine。 管道(channel) chnnel 不是線程, 是用來傳遞資訊的一個工具管道是Go語言的一個構件, 提供一種機制用於兩個goroutine之間通過傳遞一個指定類型的值來同步運行和通訊。 操作符<-用於指定管道的方向,發送或接收。如果未指定方向,則為雙向管道。 chan Sushi // 可用來發送和接收Sushi類型的值 chan <- float64 // 僅可用來發送float64類型的值 <-chan int // 僅可用來接收int類型的值
記憶一下定義 channel的時候,被指向就是發送,反而指向出去是接受。
管道是參考型別,基於make函數來分配。
* ic := make(chan int) // 不帶緩衝的int類型管道* wc := make(chan *Work, 10) // 帶緩衝的Work類型指標管道
如果通過管道發送一個值,則將<-作為二元操作符使用。 通過管道接收一個值,則將其作為一元操作符使用:
ic <- 3 // 往管道發送3work := <-wc // 從管道接收一個指向Work類型值的指標
如果管道不帶緩衝,發送方會阻塞直到接收方從管道中接收了值。 (huh…) 如果管道帶緩衝,發送方則會阻塞直到發送的值被拷貝到緩衝區內;也就是說,這個資訊只要還在管道裡面沒被使用,那麼該線程就會一直堵塞 如果緩衝區已滿,則意味著需要等待直到某個接收方擷取到一個值。 同上接收方在有值可以接收之前會一直阻塞。
關閉管道(Close)
close 函數標誌著不會再往某個管道發送值。 在調用close之後,並且在之前發送的值都被接收後,接收操作會返回一個零值,不會阻塞。 一個多傳回值的接收操作會額外返回一個布爾值用來指示返回的值是否發送操作傳遞的。
ch := make(chan string)go func() { ch <- "Hello!" close(ch)}()fmt.Println(<-ch) // 輸出字串"Hello!"fmt.Println(<-ch) // 輸出零值 - Null 字元串"",不會阻塞fmt.Println(<-ch) // 再次列印輸出Null 字元串""v, ok := <-ch // 變數v的值為空白字串"",變數ok的值為false
一個帶有range子句的for語句會依次讀取發往管道的值,直到該管道關閉:
func main() { // 譯註:要想運行該樣本,需要先定義類型Sushi,如type Sushi string var ch <-chan Sushi = Producer() for s := range ch { fmt.Println("Consumed", s) }}func Producer() <-chan Sushi { ch := make(chan Sushi) go func(){ ch <- Sushi("海老握り") // Ebi nigiri ch <- Sushi("鮪とろ握り") // Toro nigiri close(ch) }() return ch}
同步
反正就是記住 <-channelName單行的時候就是等到線程被 close 掉的。
下一個樣本中,我們讓Publish函數返回一個管道 - 用於在發布text變數值時廣播一條訊息:
// 在給定時間到期時,Publish函數會列印text變數值到標準輸出// 在text變數值發布後,該函數會關閉管道waitfunc Publish(text string, delay time.Duration) (wait <-chan struct{}) { ch := make(chan struct{}) go func() { time.Sleep(delay) fmt.Println("BREAKING NEWS:", text) close(ch) // 廣播 - 一個關閉的管道都會發送一個零值 }() return ch}
注意:我們使用了一個空結構體的管道:struct{}。 這明確地指明該管道僅用於發訊號,而不是傳遞資料。
我們可能會這樣使用這個函數:
func main() { wait := Publish("Channels let goroutines communicate.", 5*time.Second) fmt.Println("Waiting for the news...") <-wait // 等待結束 不然 The news 這個詳細輸入後 程式就退出了 fmt.Println("The news is out, time to leave.")}
這個程式會按指定的順序輸出以下三行內容。最後一行在新聞(news)一出就會立即輸出。
$ go run publish2.goWaiting for the news...BREAKING NEWS: Channels let goroutines communicate.The news is out, time to leave.
死結
反正記住沒有 close(channel) 導致另一線程一直等待
現在我們在Publish函數中引入一個bug:
func Publish(text string, delay time.Duration) (wait <-chan struct{}) { ch := make(chan struct{}) go func() { time.Sleep(delay) fmt.Println("BREAKING NEWS:", text) // 譯註:注意這裡將close函數調用注釋掉了 // close(ch) // 沒有close }() return ch}
主程式還是像之前一樣開始運行: 輸出第一行,然後等待5秒, 這時Publish函數開啟的goroutine會輸出突發新聞(breaking news), 然後退出,留下主goroutine獨自等待。
func main() { wait := Publish("Channels let goroutines communicate.", 5*time.Second) fmt.Println("Waiting for the news...") // 譯註:注意下面這一句 <-wait fmt.Println("The news is out, time to leave.")}
此刻之後,程式無法再繼續往下執行。眾所周知,這種情形即為死結。
死結是線程之間相互等待,其中任何一個都無法向前啟動並執行情形。
Go語言對於運行時的死結檢測具備良好的支援。 當沒有任何goroutine能夠往前執行的情形發生時, Go程式通常會提供詳細的錯誤資訊。以下就是我們的問題程式的輸出:
Waiting for the news...BREAKING NEWS: Channels let goroutines communicate.fatal error: all goroutines are asleep - deadlock!goroutine 1 [chan receive]:main.main() .../goroutineStop.go:11 +0xf6goroutine 2 [syscall]:created by runtime.main .../go/src/pkg/runtime/proc.c:225goroutine 4 [timer goroutine (idle)]:created by addtimer .../go/src/pkg/runtime/ztime_linux_amd64.c:73
大多數情況下找出Go程式中造成死結的原因都比較容易,那麼剩下的就是如何解決這個bug了。 資料競爭(data race)
死結也許聽起來令人挺憂傷的,但伴隨並發編程真正災難性的錯誤其實是資料競爭, 相當常見,也可能非常難於調試。
當兩個線程並發地訪問同一個變數,並且其中至少一個訪問是寫操作時,資料競爭就發生了。
下面的這個函數就有資料競爭問題,其行為是未定義的。 例如,可能輸出數值1。代碼之後是一個可能性解釋,試圖搞清楚這一切是如何發生的。
func race() { wait := make(chan struct{}) n := 0 go func() { // 譯註:注意下面這一行 n++ // 一次訪問: 讀, 遞增, 寫 close(wait) }() // 譯註:注意下面這一行 n++ // 另一次衝突的訪問 <-wait fmt.Println(n) // 輸出:未指定}
n++ 實際上不是直接在記憶體上面 +1 而是把 n 放到 cpu 二級緩衝裡面,處理好了再存到記憶體裡面
代碼中的兩個goroutine(假設命名為g1和g2)參與了一次競爭, 我們無法獲知操作會以何種順序發生。以下是諸多可能中的一種:
處理順序: g1 從 n 中擷取值0 g2 從 n 中擷取值0 也就是說 先放到執行緒計數器裡面不是直接操作記憶體 g1 將值從0增大到1 g1 將1寫到 n g2 將值從0增大到1 g2 將1寫到 n 程式輸出 n 的值,當前為1
“資料競爭(data race)” 這名字有點誤導的嫌疑。不僅操作的順序是未定義的, 其實根本沒有任何保證(no guarantees whatsoever)。 編譯器和硬體為了得到更好的效能,經常都會對代碼進行上下內外的順序變換。 如果你看到一個線程處於中間行為狀態時,那麼當時的情境可能就像下圖所示的一樣:
避免資料競爭的唯一方式是線程間同步訪問所有的共用可變資料。有幾種方式能夠實現這一目標。 Go語言中,通常是使用管道或者鎖。(sync和sync/atomic包中還有更低層次的機制可供使用,但本文中不做討論)。
Go語言中,處理並發資料訪問的推薦方式是使用管道從一個goroutine中往下一個goroutine傳遞實際的資料。 有格言說得好:“不要通過共用記憶體來通訊,而是通過通訊來共用記憶體”。 使用局部變數
func sharingIsCaring() { ch := make(chan int) go func() { n := 0 // 僅為一個goroutine可見的局部變數. n++ ch <- n // 資料從一個goroutine離開... }() n := <-ch // ...然後安全到達另一個goroutine. n++ fmt.Println(n) // 輸出: 2}
以上代碼中的管道肩負雙重責任 - 從一個goroutine將資料傳遞到另一個goroutine, 並且起到同步的作用:發送方goroutine會等待另一個goroutine接收資料, 接收方goroutine也會等待另一個goroutine發送資料。
Go語言記憶體模型 - 要保證一個goroutine中對一個變數的讀操作得到的值正好是另一個goroutine中對同一個變數寫操作產生的值, 條件相當複雜,但goroutine之間只要通過管道來共用所有可變資料,那麼就能遠離資料競爭了。 互斥鎖
有時,通過顯式加鎖,而不是使用管道,來同步資料訪問,可能更加便捷。 Go語言標準庫為這一目的提供了一個互斥鎖 - sync.Mutex。
要想這類加鎖起效的話,關鍵之處在於:所有對共用資料的訪問,不管讀寫,僅當goroutine持有鎖才能操作。 一個goroutine出錯就足以破壞掉一個程式,引入資料競爭。
因此,應該設計一個自訂資料結構,具備明確的API,確保所有的同步都在資料結構內部完成。 下例中,我們構建了一個安全、便於使用的並發資料結構,AtomicInt,用於儲存一個整型值。 任意數量的goroutine都能通過Add和Value方法安全地訪問這個數值。
遇到問題就加鎖,看誰敢動
// AtomicInt是一個並發資料結構,持有一個整數值 // 該資料結構的零值為0 type AtomicInt struct { mu sync.Mutex // 鎖,一次僅能被一個goroutine持有。 n int}// Add方法作為一個原子操作將n加到AtomicIntfunc (a *AtomicInt) Add(n int) { a.mu.Lock() // 等待鎖釋放,然後持有它 a.n += n a.mu.Unlock() // 釋放鎖}// Value方法返回a的值func (a *AtomicInt) Value() int { a.mu.Lock() n := a.n a.mu.Unlock() // 整個結構被解鎖了 return n}func lockItUp() { wait := make(chan struct{}) var n AtomicInt go func() { n.Add(1) // 一個訪問 close(wait) }() n.Add(1) // 另一個並發訪問 <-wait fmt.Println(n.Value()) // 輸出: 2}
檢測資料競爭
競爭有時非常難於檢測。
下例中的這個函數有一個資料競爭問題,執行這個程式時會輸出55555。
嘗試一下,也許你會得到一個不同的結果。
sync.WaitGroup是Go語言標準庫的一部分;用於等待一組goroutine結束運行。
func race() { var wg sync.WaitGroup wg.Add(5) // 譯註:注意下面這行代碼中的i++ for i := 0; i < 5; i++ { go func() { // 注意下一行代碼會輸出什麼。為什麼。 fmt.Print(i) // 6個goroutine共用變數i wg.Done() }() } wg.Wait() // 等待所有(5個)goroutine運行結束 fmt.Println()}raceClosure.go
對於輸出55555,一個貌似合理的解釋是: 執行i++的goroutine在其他goroutine執行列印語句之前就完成了5次i++操作。 實際上變數i更新後的值為其他goroutine所見純屬巧合。
一個簡單的解決方案是:使用一個局部變數,然後當開啟新的goroutine時,將數值作為參數傳遞: 使用局部變數
func correct() { var wg sync.WaitGroup wg.Add(5) for i := 0; i < 5; i++ { go func(n int) { // 使用局部變數 fmt.Print(n) wg.Done() }(i) } wg.Wait() fmt.Println()}
這次代碼就對了,程式會輸出期望的結果,如:24031。 注意:goroutine之間的運行順序是不確定的。
依然使用閉包,但能夠避免資料競爭也是可能的, 必須小心翼翼地讓每個goroutine使用一個專屬的變數。
func alsoCorrect() { var wg sync.WaitGroup // 使用 WaitGroup 進階貨 wg.Add(5) for i := 0; i < 5; i++ { n := i // 為每個閉包建立一個專屬的變數 go func() { fmt.Print(n) wg.Done() }() } wg.Wait() fmt.Println()}
資料競爭自動檢測
一般來說,不太可能能夠自動檢測發現所有可能的資料競爭情況, 但Go(從版本1.1開始)有一個強大的資料競爭檢測器。
這個工具用起來也很簡單:只要在使用go命令時加上-race標記即可。 開啟檢測器運行上面的程式會給出清晰且資訊量大的輸出:
$ go run -race raceClosure.goRace:==================WARNING: DATA RACERead by goroutine 2: main.func·001() ../raceClosure.go:22 +0x65Previous write by goroutine 0: main.race() ../raceClosure.go:20 +0x19b main.main() ../raceClosure.go:10 +0x29 runtime.main() ../go/src/pkg/runtime/proc.c:248 +0x91Goroutine 2 (running) created at: main.race() ../raceClosure.go:24 +0x18b main.main() ../raceClosure.go:10 +0x29 runtime.main() ../go/src/pkg/runtime/proc.c:248 +0x91==================55555Correct:01234Also correct:01324Found 1 data race(s)exit status 66
該工具發現一處資料競爭,包含:一個goroutine在第20行對一個變數進行寫操作, 跟著另一個goroutine在第22行對同一個變數進行了未同步的讀操作。
注意:競爭檢測器只能發現在運行期確實發生的資料競爭(譯註:我也不太理解這話,請指導) Select語句
select語句是Go語言並發工具集中的終極工具。 select用於從一組可能的通訊中選擇一個進一步處理。 如果任意一個通訊都可以進一步處理, 則從中隨機播放一個,執行對應的語句。 否則,如果又沒有預設分支(default case),select語句則會阻塞, 直到其中一個通訊完成。
以下是一個玩具樣本,示範select語句如何用於實現一個隨機數產生器:
// RandomBits函數 返回一個管道,用於產生一個位元隨機序列func RandomBits() <-chan int { ch := make(chan int) go func() { for { select { case ch <- 0: // 注意:分支沒有對應的處理語句 case ch <- 1: } } }() return ch}
下面是相對更加實際一點的例子:如何使用select語句為一個操作設定一個時間限制。 代碼會輸出變數news的值或者逾時訊息,具體依賴於兩個接收語句哪個先執行:
select {case news := <-NewsAgency: fmt.Println(news)case <-time.After(time.Minute): fmt.Println("Time out: no news in one minute.")}
函數 time.After 是Go語言標準庫的一部分; 它會在等待指定時間後將當前的時間發送到返回的管道中。
綜合所有樣本
花點時間認真研究一下這個樣本。如果你完全理解,也就對Go語言中並發的應用方式有了全面的掌握。
這個程式示範了如何將管道用於被任意數量的goroutine發送和接收資料,也示範了如何將select語句用於從多個通訊中選擇一個。
func main() { people := []string{"Anna", "Bob", "Cody", "Dave", "Eva"} match := make(chan string, 1) // 為一個未匹配的發送操作提供空間 wg := new(sync.WaitGroup) wg.Add(len(people)) for _, name := range people { go Seek(name, match, wg) } wg.Wait() select { case name := <-match: fmt.Printf("No one received %s’s message.\n", name) default: // 沒有待處理的發送操作 }}// 函數Seek 發送一個name到match管道或從match管道接收一個peer,結束時通知wait groupfunc Seek(name string, match chan string, wg *sync.WaitGroup) { select { case peer := <-match: fmt.Printf("%s sent a message to %s.\n", peer, name) case match <- name: // 等待某個goroutine接收我的訊息 } wg.Done()}
樣本輸出:
$ go run matching.goCody sent a message to Bob.Anna sent a message to Eva.No one received Dave’s message.
並行計算
並發的一個應用是將一個大的計算切分成一些工作單元,調度到不同的CPU上同時地計算。
將計算分布到多個CPU上更多是一門藝術,而不是一門科學。以下是一些經驗法則: 每個工作單元應該花費大約100微秒到1毫秒的時間用於計算。如果單元粒度太小,切分問題以及調度子問題的管理開銷可能就會太大。如果單元粒度太大,整個計算也許不得不等待一個慢的工作項目結束。這種緩慢可能因為多種原因而產生,比如:調度、其他進程的中斷或者糟糕的記憶體布局。(注意:工作單元的數目是不依賴於CPU的數目的) 儘可能減小共用的資料量。並發寫操作的代價非常大,特別是如果goroutine運行在不同的CPU上。讀操作之間的資料共用則通常不會是個問題。 資料訪問盡量利用良好的局部性。如果資料能保持在緩衝中,資料載入和儲存將會快得多得多,這對於寫操作也格外地重要。
下面的這個樣本展示如何切分一個開銷很大的計算並將其分布在所有可用的CPU上進行計算。先看一下有待最佳化的代碼:
type Vector []float64// 函數Convolve 計算 w = u * v,其中 w[k] = Σ u[i]*v[j], i + j = k// 先決條件:len(u) > 0, len(v) > 0func Convolve(u, v Vector) (w Vector) { n := len(u) + len(v) - 1 w = make(Vector, n) for k := 0; k < n; k++ { w[k] = mul(u, v, k) } return}// 函數mul 返回 Σ u[i]*v[j], i + j = k.func mul(u, v Vector, k int) (res float64) { n := min(k+1, len(u)) j := min(k, len(v)-1) for i := k - j; i < n; i, j = i+1, j-1 { res += u[i] * v[j] } return}
思路很簡單:確定合適大小的工作單元,然後在不同的goroutine中執行每個工作單元。 以下是並發版本的 Convolve:
func Convolve(u, v Vector) (w Vector) { n := len(u) + len(v) - 1 w = make(Vector, n) // 將 w 切分成花費 ~100μs-1ms 用於計算的工作單元 size := max(1, 1<<20/n) wg := new(sync.WaitGroup) wg.Add(1 + (n-1)/size) for i := 0; i < n && i >= 0; i += size { // 整型溢出後 i < 0 j := i + size if j > n || j < 0 { // 整型溢出後 j < 0 j = n } // 這些goroutine共用記憶體,但是唯讀 go func(i, j int) { for k := i; k < j; k++ { w[k] = mul(u, v, k) } wg.Done() }(i, j) } wg.Wait() return}
工作單元定義之後,通常情況下最好將調度工作交給運行時和作業系統。然而, 對於 Go 1.* 你也許需要告訴運行時希望多少個goroutine來同時地運行代碼。
func init() { numcpu := runtime.NumCPU() runtime.GOMAXPROCS(numcpu) // 嘗試使用所有可用的CPU}
附加:理解golang並發原理