《Go 語言程式設計》讀書筆記 (五) 協程與通道

KevinYan發表於2020-01-01

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)

img

向快取Channel的傳送操作就是向內部快取佇列的尾部插入元素,接收操作則是從佇列的頭部刪除元素。如果內部快取佇列是滿的,那麼傳送操作將阻塞直到因另一個goroutine執行接收操作而釋放了新的佇列空間。相反,如果channel是空的,接收操作將阻塞直到有另一個goroutine執行傳送操作而向佇列插入元素。

我們可以在無阻塞的情況下連續向新建立的channel傳送三個值:

ch <- "A"
ch <- "B"
ch <- "C"

此刻,channel的內部快取佇列將是滿的(圖8.3),如果有第四個傳送操作將發生阻塞。

img

如果我們接收一個值,

fmt.Println(<-ch) // "A"

那麼channel的快取佇列將不是滿的也不是空的(圖8.4),因此對該channel執行的傳送或接收操作都不會傳送阻塞。透過這種方式,channel的快取佇列解耦了接收和傳送的goroutine。

img

在某些特殊情況下,程式可能需要知道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 協議》,轉載必須註明作者和本文連結
公眾號:網管叨bi叨 | Golang、Laravel、Docker、K8s等學習經驗分享

相關文章