Goroutines
- 在Go語言中,每一個併發的執行單元叫作goroutine。設想一個程式中有兩個函式,假設兩個函式沒有相互之間的呼叫關係。一個線性的程式會先呼叫其中的一個函式,然後再呼叫另一個。如果程式中包含多個goroutine,對兩個函式的呼叫則可能發生在同一時刻。
- 當一個程式啟動時,其main函式即在一個單獨的goroutine中執行,我們叫它main goroutine。新的goroutine會用go語句來建立。在語法上,go語句是在一個普通的函式或方法呼叫前加上關鍵字go。go語句會使其語句中的函式在一個新建立的goroutine中執行。而go語句本身會迅速地完成。
f() // call f(); wait for it to return
go f() // create a new goroutine that calls f(); don't wait
- 主函式返回時,所有的goroutine都會被直接打斷,程式退出。除了從主函式退出或者直接終止程式之外,沒有其它的程式設計方法能夠讓一個goroutine來打斷另一個的執行,但是之後可以看到一種方式來實現這個目的,透過goroutine之間的通訊來讓一個goroutine請求其它的goroutine,並被請求的goroutine自行結束執行。
Channel
- 如果說goroutine是Go語言程式的併發體的話,那麼channels它們之間的通訊機制。它可以讓一個goroutine透過它給另一個goroutine傳送值資訊。每個channel都有一個特殊的型別,也就是channel可傳送資料的型別。一個可以傳送int型別資料的channel一般寫為chan int。
- 使用內建的make函式,我們可以建立一個channel:
ch := make(chan int)
和map類似,channel也一個對make函式建立的底層資料結構的引用。當我們複製一個channel或把 channel用於函式引數傳遞時,我們只是複製了一個channel引用,因此呼叫者和被呼叫者將引用同一個channel物件。和其它的引用型別一樣,channel的零值也是nil。
channel有傳送和接收兩個主要操作,都是通訊行為。一個傳送語句將一個值從一個goroutine透過channel傳送到另一個執行接收操作的goroutine。傳送和接收兩個操作都是用
<-
運算子。在傳送語句中,<-
運算子分割channel和要傳送的值。在接收語句中,<-
運算子寫在channel物件之前。一個不使用接收結果的接收操作也是合法的。
ch <- x // 傳送訊息
x = <-ch // 從 channel 中接收訊息
<-ch // 從 channel 接收並丟棄訊息
Channel還支援close操作,用於關閉channel,隨後對基於該channel的任何傳送操作都將導致panic異常。對一個已經被close過的channel執行接收操作依然可以接收到之前已經成功傳送的資料;如果channel中已經沒有資料的話將產生一個零值的資料。
使用內建的close函式就可以關閉一個channel:
close(ch)
以最簡單方式呼叫make函式建立的時一個無緩衝的channel,但是我們也可以指定第二個整形引數,對應channel的容量。如果channel的容量大於零,那麼該channel就是帶緩衝的channel。
ch = make(chan int) // unbuffered channel
ch = make(chan int, 0) // unbuffered channel
ch = make(chan int, 3) // buffered channel with capacity 3
無緩衝 channel
一個基於無緩衝Channel的傳送操作將導致傳送者goroutine阻塞,直到另一個goroutine在相同的Channel上執行接收操作,當傳送的值透過Channel成功傳輸之後,兩個goroutine可以繼續執行後面的語句。反之,如果接收操作先發生,那麼接收者goroutine也將阻塞,直到有另一個goroutine在相同的Channel上執行傳送操作。
下面的程式在 main 函式的 goroutine 中將標準輸入複製到server,因此當客戶端程式關閉標準輸入時,後臺goroutine可能依然在工作。我們需要讓主goroutine等待後臺goroutine完成工作後再退出,我們使用了一個channel來同步兩個goroutine,在後臺goroutine返回之前,它先列印一個日誌資訊,然後向done對應的channel傳送一個值。主goroutine在退出前先等待從done對應的channel接收一個值。因此,總是可以在程式退出前正確輸出“done”訊息。
func main() {
conn, err := net.Dial("tcp", "localhost:8000")
if err != nil {
log.Fatal(err)
}
done := make(chan struct{})
go func() {
io.Copy(os.Stdout, conn) // NOTE: ignoring errors
log.Println("done")
done <- struct{}{} // signal the main goroutine
}()
mustCopy(conn, os.Stdin)
conn.Close()
<-done // wait for background goroutine to finish
}
基於channel傳送訊息有兩個重要方面。首先每個訊息都有一個值,但是有時候通訊的事實和發生的時刻也同樣重要。當我們更希望強調通訊發生的時刻時,我們將它稱為訊息事件。有些訊息事件並不攜帶額外的資訊,它僅僅是用作兩個goroutine之間的同步,這時候我們可以用
struct{}
空結構體作為channels元素的型別,雖然也可以使用bool或int型別實現同樣的功能,done <- 1
語句也比done <- struct{}{}
更短。如果傳送者知道,沒有更多的值需要傳送到channel的話,那麼讓接收者也能及時知道沒有多餘的值可接收將是有用的,因為接收者可以停止不必要的接收等待。這可以透過內建的close函式來關閉channel實現:
close(naturals)
- 當一個channel被關閉後,再向該channel傳送資料將導致panic異常。當一個被關閉的channel中已經傳送的資料都被成功接收後,後續的接收操作將不再阻塞,它們會立即返回一個零值。
- 接收 channel 語句中可以額外增加第二個值,標識 chnnel 是否已經關閉
x, ok := <-naturals
- Go語言的range迴圈可直接在channels上面迭代。使用range迴圈是上面處理模式的簡潔語法,它依次從channel接收資料,當channel被關閉並且沒有值可接收時跳出迴圈。
在下面的程式中,我們的計數器goroutine只生成100個含數字的序列,然後關閉naturals對應的channel,這將導致計算平方數的squarer對應的goroutine可以正常終止迴圈並關閉squares對應的channel。(在一個更復雜的程式中,可以透過defer語句關閉對應的channel。)最後,主goroutine也可以正常終止迴圈並退出程式。
func main() {
naturals := make(chan int)
squares := make(chan int)
// Counter
go func() {
for x := 0; x < 100; x++ {
naturals <- x
}
close(naturals)
}()
// Squarer
go func() {
for x := range naturals {
squares <- x * x
}
close(squares)
}()
// Printer (in main goroutine)
for x := range squares {
fmt.Println(x)
}
}
- 試圖重複關閉一個channel將導致panic異常,試圖關閉一個nil值的channel也將導致panic異常。關閉一個channels還會觸發一個廣播機制,我們將在後面討論。
單方向的 channel
當一個channel作為一個函式引數是,它一般總是被專門用於只傳送或者只接收。
為了表明這種意圖並防止被濫用,Go語言的型別系統提供了單方向的channel型別,分別用於只傳送或只接收的channel。型別
chan<- int
表示一個只傳送int的channel,只能傳送不能接收。相反,型別<-chan int
表示一個只接收int的channel,只能接收不能傳送。(箭頭<-
和關鍵字chan的相對位置表明了channel的方向。)這種限制將在編譯期檢測。因為關閉操作只用於斷言不再向channel傳送新的資料,所以只有在傳送者所在的goroutine才會呼叫close函式,因此對一個只接收的channel呼叫close將是一個編譯錯誤。
這是改進的版本,這一次引數使用了單方向channel型別:
func counter(out chan<- int) {
for x := 0; x < 100; x++ {
out <- x
}
close(out)
}
func squarer(out chan<- int, in <-chan int) {
for v := range in {
out <- v * v
}
close(out)
}
func printer(in <-chan int) {
for v := range in {
fmt.Println(v)
}
}
func main() {
naturals := make(chan int)
squares := make(chan int)
go counter(naturals)
go squarer(squares, naturals)
printer(squares)
}
呼叫counter(naturals)將導致將chan int
型別的naturals隱式地轉換為chan<- int
型別只傳送型的channel。呼叫printer(squares)也會導致相似的隱式轉換,這一次是轉換為<-chan int
型別只接收型的channel。任何雙向channel向單向channel變數的賦值操作都將導致該隱式轉換。
帶緩衝的 channel
帶快取的Channel內部持有一個元素佇列。佇列的最大容量是在呼叫make函式建立channel時透過第二個引數指定的。下面的語句建立了一個可以持有三個字串元素的帶快取Channel。圖8.2是ch變數對應的channel的圖形表示形式。
ch = make(chan string, 3)
向快取Channel的傳送操作就是向內部快取佇列的尾部插入元素,接收操作則是從佇列的頭部刪除元素。如果內部快取佇列是滿的,那麼傳送操作將阻塞直到因另一個goroutine執行接收操作而釋放了新的佇列空間。相反,如果channel是空的,接收操作將阻塞直到有另一個goroutine執行傳送操作而向佇列插入元素。
我們可以在無阻塞的情況下連續向新建立的channel傳送三個值:
ch <- "A"
ch <- "B"
ch <- "C"
此刻,channel的內部快取佇列將是滿的(圖8.3),如果有第四個傳送操作將發生阻塞。
如果我們接收一個值,
fmt.Println(<-ch) // "A"
那麼channel的快取佇列將不是滿的也不是空的(圖8.4),因此對該channel執行的傳送或接收操作都不會傳送阻塞。透過這種方式,channel的快取佇列解耦了接收和傳送的goroutine。
在某些特殊情況下,程式可能需要知道channel內部快取的容量,可以用內建的cap函式獲取:
fmt.Println(cap(ch)) // "3"
同樣,對於內建的len函式,如果傳入的是channel,那麼將返回channel內部快取佇列中有效元素的個數。因為在併發程式中該資訊會隨著接收操作而失效,但是它對某些故障診斷和效能最佳化會有幫助。
fmt.Println(len(ch)) // "2"
在繼續執行兩次接收操作後channel內部的快取佇列將又成為空的,如果有第四個接收操作將發生阻塞:
fmt.Println(<-ch) // "B"
fmt.Println(<-ch) // "C"
下面的例子展示了一個使用了帶快取channel的應用。它併發地向三個映象站點發出請求,三個映象站點分散在不同的地理位置。它們分別將收到的響應傳送到帶快取channel,最後接收者只接收第一個收到的響應,也就是最快的那個響應。因此mirroredQuery函式可能在另外兩個響應慢的映象站點響應之前就返回了結果。(順便說一下,多個goroutines併發地向同一個channel傳送資料,或從同一個channel接收資料都是常見的用法。)
func mirroredQuery() string {
responses := make(chan string, 3)
go func() { responses <- request("asia.gopl.io") }()
go func() { responses <- request("europe.gopl.io") }()
go func() { responses <- request("americas.gopl.io") }()
return <-responses // return the quickest response
}
func request(hostname string) (response string) { /* ... */ }
如果我們使用了無快取的channel,那麼兩個慢的goroutines將會因為沒有人接收而被永遠卡住。這種情況,稱為goroutines洩漏,這將是一個BUG。和垃圾變數不同,洩漏的goroutines並不會被自動回收,因此確保每個不再需要的goroutine能正常退出是重要的。
關於無快取或帶快取channel之間的選擇,或者是帶快取channel的容量大小的選擇,都可能影響程式的正確性。無快取channel更強地保證了每個傳送操作與相應的同步接收操作;但是對於帶快取channel,這些操作是解耦的。同樣,即使我們知道將要傳送到一個channel的資訊的數量上限,建立一個對應容量大小帶快取channel也是不現實的,因為這要求在執行任何接收操作之前快取所有已經傳送的值。如果未能分配足夠的緩衝將導致程式死鎖。
用帶緩衝的channel 控制併發數量
此外對於buffered channel,我們可以用一個有容量限制的buffered channel來控制併發,這類似於作業系統裡的計數訊號量概念。從概念上講,channel裡的n個空槽代表n個可以處理內容的token(通行證),從channel裡接收一個值會釋放其中的一個token,並且生成一個新的空槽位。這樣保證了在沒有接收介入時最多有n個傳送操作。(這裡可能我們拿channel裡填充的槽來做token更直觀一些,不過還是這樣吧~)。由於channel裡的元素型別並不重要,我們用一個零值的struct{}來作為其元素。
下面的crawl
函式,將對links.Extract
的呼叫操作用獲取、釋放token的操作包裹起來,來確保同一時間對其只有20個呼叫。訊號量數量和其能操作的IO
資源數量應保持接近。
// goroutine獲取token後,可以進行抓取操作,如果滿20了
// 那麼 goroutine 會等到有獲取 token 後再去執行
var tokens = make(chan struct{}, 20)
func crawl(url string) []string {
fmt.Println(url)
tokens <- struct{}{} // 獲取 token
list, err := links.Extract(url)
<-tokens // 釋放 token
if err != nil {
log.Print(err)
}
return list
}
併發迴圈的一個典型示例
在併發迴圈中為了知道最後一個goroutine什麼時候結束(最後一個結束並不一定是最後一個開始),我們需要一個遞增的計數器,在每一個goroutine啟動時加一,在goroutine退出時減一。這需要一種特殊的計數器,這個計數器需要在多個goroutine操作時做到安全並且提供在其減為零之前一直等待的一種方法。這種計數型別被稱為sync.WaitGroup,下面的程式碼就用到了這種方法:
// makeThumbnails6為從通道中接收到的每個檔案建立縮圖。
// 返回每個建立的縮圖所佔的自己數。
func makeThumbnails6(filenames <-chan string) int64 {
sizes := make(chan int64)
var wg sync.WaitGroup // number of working goroutines
for f := range filenames {
wg.Add(1)
// worker
go func(f string) {
defer wg.Done()
thumb, err := thumbnail.ImageFile(f)
if err != nil {
log.Println(err)
return
}
info, _ := os.Stat(thumb) // OK to ignore error
sizes <- info.Size()
}(f)
}
// closer
go func() {
wg.Wait()
close(sizes)
}()
var total int64
for size := range sizes {
total += size
}
return total
}
注意Add和Done方法的不對策。Add是為計數器加一,必須在worker goroutine開始之前呼叫,而不是在goroutine中;否則的話我們沒辦法確定Add是在”closer” goroutine呼叫Wait之前被呼叫。並且Add還有一個引數,但Done卻沒有任何引數;其實它和Add(-1)是等價的。我們使用defer來確保計數器即使是在出錯的情況下依然能夠正確地被減掉。上面的程式程式碼結構是當我們使用併發迴圈,但又不知道迭代次數時很通常而且很地道的寫法。
select多通道複用
select語句的一般形式,和switch語句稍微有點相似。也會有幾個case和最後的default選擇支。每一個case代表一個通訊操作(在某個channel上進行傳送或者接收)並且會包含一些語句組成的一個語句塊。
select {
case <-ch1:
// ...
case x := <-ch2:
// ...use x...
case ch3 <- y:
// ...
default:
// ...
}
一個接收表示式可能只包含接收表示式自身(譯註:不把接收到的值賦值給變數什麼的),就像上面的第一個case,或者包含在一個簡短的變數宣告中,像第二個case裡一樣;第二種形式讓你能夠在當前 case 塊中引用接收到的值。
select會等待case中有能夠執行的case時去執行。當條件滿足時,select才會去通訊並執行case之後的語句;這時候其它通訊是不會執行的,當沒有 case 準備好時 select 會去執行 default 之後的語句,使用default 分支可以避免 select 的阻塞。一個沒有任何case的select語句寫作select{},會永遠地等待下去。
下面這個例子更微秒。ch這個channel的buffer大小是1,所以會交替的為空或為滿,所以只有一個case可以進行下去,無論i是奇數或者偶數,它都會列印0 2 4 6 8。
ch := make(chan int, 1)
for i := 0; i < 10; i++ {
select {
case x := <-ch:
fmt.Println(x) // "0" "2" "4" "6" "8"
case ch <- i:
}
}
如果多個case同時就緒時,select會隨機地選擇一個執行,這樣來保證每一個channel都有平等的被select的機會。增加上面例子的buffer大小會使其輸出變得不確定,因為當buffer既不為滿也不為空時,select語句的執行情況就像是拋硬幣的行為一樣是隨機的。
本作品採用《CC 協議》,轉載必須註明作者和本文連結