Go語言並行存取模型:像Unix Pipe那樣使用channel

來源:互聯網
上載者:User
這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。

簡介

Go語言的並發原語允許開發人員以類似於 Unix Pipe 的方式構建資料流水線 (data pipelines),資料流水線能夠高效地利用 I/O和多核 CPU 的優勢。

本文要講的就是一些使用流水線的一些例子,流水線的錯誤處理也是本文的重點。

閱讀建議

資料流水線充分利用了多核特性,代碼層面是基於 channel 類型 和 go 關鍵字。

channel 和 go 貫穿本文的始終。如果你對這兩個概念不太瞭解,建議先閱讀之前公眾號發布的兩篇文章:Go 語言記憶體模型(上/下)。

如果你對作業系統中"生產者"和"消費者"模型比較瞭解的話,也將有助於對本文中流水線的理解。

本文中絕大多數講解都是基於代碼進行的。換句話說,如果你看不太懂某些程式碼片段,建議補全以後,在機器或play.golang.org 上運行一下。對於某些不明白的細節,可以手動添加一些語句以助於理解。

由於 Go語言並行存取模型 的英文原文 Go Concurrency Patterns: Pipelines and cancellation 篇幅比較長,本文只包含 理論推導和簡單的例子。
下一篇文章我們會對 "並行MD5" 這個現實生活的例子進行詳細地講解。

什麼是 "流水線" (pipeline)?

對於"流水線"這個概念,Go語言中並沒有正式的定義,它只是很多種並發方式的一種。這裡我給出一個非官方的定義:一條流水線是 是由多個階段組成的,相鄰的兩個階段由 channel 進行串連;
每個階段是由一組在同一個函數中啟動的 goroutine 組成。在每個階段,這些 goroutine 會執行下面三個操作:

  1. 通過 inbound channels 從上遊接收資料

  2. 對接收到的資料執行一些操作,通常會產生新的資料

  3. 將新產生的資料通過 outbound channels 發送給下遊

除了第一個和最後一個階段,每個階段都可以有任意個 inbound 和 outbound channel。
顯然,第一個階段只有 outbound channel,而最後一個階段只有 inbound channel。
我們通常稱第一個階段為"生產者""源頭",稱最後一個階段為"消費者""接收者"

首先,我們通過一個簡單的例子來示範這個概念和其中的技巧。後面我們會更出一個真實世界的例子。

流水線入門:求平方數

假設我們有一個流水線,它由三個階段組成。

第一階段是 gen 函數,它能夠將一組整數轉換為channel,channel 可以將數字發送出去。
gen 函數首先啟動一個 goroutine,該goroutine 發送數字到 channel,當數字發送完時關閉channel。
代碼如下:

func gen(nums ...int) <-chan int {    out := make(chan int)    go func() {        for _, n := range nums {            out <- n        }        close(out)    }()    return out}

第二階段是 sq 函數,它從 channel 接收一個整數,然後返回 一個channel,返回的channel可以發送 接收到整數的平方。
當它的 inbound channel 關閉,並且把所有數字均發送到下遊時,會關閉 outbound channel。代碼如下:

func sq(in <-chan int) <-chan int {    out := make(chan int)    go func() {        for n := range in {            out <- n * n        }        close(out)    }()    return out}

main 函數 用於設定流水線並運行最後一個階段。最後一個階段會從第二階段接收數字,並逐個列印出來,直到來自於上遊的 inbound channel關閉。代碼如下:

func main() {    // 設定流水線    c := gen(2, 3)    out := sq(c)    // 消費輸出結果    fmt.Println(<-out) // 4    fmt.Println(<-out) // 9}

由於 sq 函數的 inbound channel 和 outbound channel 類型一樣,所以組合任意個 sq 函數。比如像下面這樣使用:

func main() {    // 設定流水線並消費輸出結果    for n := range sq(sq(gen(2, 3))) {        fmt.Println(n) // 16 then 81    }}

如果我們稍微修改一下 gen 函數,便可以類比 haskell的惰性求值。有興趣的讀者可以自己折騰一下。

流水線進階:扇入和扇出

扇出:同一個 channel 可以被多個函數讀取資料,直到channel關閉。
這種機制允許將工作負載分發到一組worker,以便更好地並行使用 CPU 和 I/O。

扇入:多個 channel 的資料可以被同一個函數讀取和處理,然後合并到一個 channel,直到所有 channel都關閉。

下面這張圖對 扇入 有一個直觀的描述:

我們修改一下上個例子中的流水線,這裡我們運行兩個 sq 執行個體,它們從同一個 channel 讀取資料。
這裡我們引入一個新函數 merge 對結果進行"扇入"操作:

func main() {    in := gen(2, 3)    // 啟動兩個 sq 執行個體,即兩個goroutines處理 channel "in" 的資料    c1 := sq(in)    c2 := sq(in)    // merge 函數將 channel c1 和 c2 合并到一起,這段代碼會消費 merge 的結果    for n := range merge(c1, c2) {        fmt.Println(n) // 列印 4 9, 或 9 4    }}

merge 函數 將多個 channel 轉換為一個 channel,它為每一個 inbound channel 啟動一個 goroutine,用於將資料
拷貝到 outbound channel。
merge 函數的實現見下面代碼 (注意 wg 變數):

func merge(cs ...<-chan int) <-chan int {    var wg sync.WaitGroup    out := make(chan int)    // 為每一個輸入channel cs 建立一個 goroutine output    // output 將資料從 c 拷貝到 out,直到 c 關閉,然後 調用 wg.Done    output := func(c <-chan int) {        for n := range c {            out <- n        }        wg.Done()    }    wg.Add(len(cs))    for _, c := range cs {        go output(c)    }    // 啟動一個 goroutine,用於所有 output goroutine結束時,關閉 out     // 該goroutine 必須在 wg.Add 之後啟動    go func() {        wg.Wait()        close(out)    }()    return out}

在上面的代碼中,每個 inbound channel 對應一個 output 函數。所有 output goroutine 被建立以後,merge 啟動一個額外的 goroutine,
這個goroutine會等待所有 inbound channel 上的發送操作結束以後,關閉 outbound channel。

對已經關閉的channel 執行發送操作(ch<-)會導致異常,所以我們必須保證所有的發送操作都在關閉channel之前結束。
sync.WaitGroup 提供了一種組織同步的方式。
它保證 merge 中所有 inbound channel (cs ...<-chan int) 均被正常關閉, output goroutine 正常結束後,關閉 out channel。

停下來思考一下

在使用流水線函數時,有一個固定的模式:

  1. 在一個階段,當所有發送操作 (ch<-) 結束以後,關閉 outbound channel

  2. 在一個階段,goroutine 會持續從 inbount channel 接收資料,直到所有 inbound channel 全部關閉

在這種模式下,每一個接收階段都可以寫成 range 迴圈的方式,
從而保證所有資料都被成功發送到下遊後,goroutine能夠立即退出。

在現實中,階段並不總是接收所有的 inbound 資料。有時候是設計如此:接收者可能只需要資料的一個子集就可以繼續執行。
更常見的情況是:由於前一個階段返回一個錯誤,導致該階段提前退出。
這兩種情況下,接收者都不應該繼續等待後面的值被傳送過來。

我們期望的結果是:當後一個階段不需要資料時,上遊階段能夠停止生產。

在我們的例子中,如果一個階段不能消費所有的 inbound 資料,試圖發送這些資料的 goroutine 會永久阻塞。看下面這段程式碼片段:

    // 只消費 out 的第一個資料    out := merge(c1, c2)    fmt.Println(<-out) // 4 or 9    return    // 由於我們不再接收 out 的第二個資料    // 其中一個 goroutine output 將會在發送時被阻塞}

顯然這裡存在資源泄漏。一方面goroutine 消耗記憶體和運行時資源,另一方面goroutine 棧中的堆引用會阻止 gc 執行回收操作。
既然goroutine 不能被回收,那麼他們必須自己退出。

我們重新整理一下流水線中的不同階段,保證在下遊階段接收資料失敗時,上遊階段也能夠正常退出。
一個方式是使用帶有緩衝的管道作為 outbound channel。緩衝可以儲存固定個數的資料。
如果緩衝沒有用完,那麼發送操作會立即返回。看下面這段程式碼範例:

c := make(chan int, 2) // 緩衝大小為 2c <- 1  // 立即返回c <- 2  // 立即返回c <- 3  // 該操作會被阻塞,直到有一個 goroutine 執行 <-c,並接收到數字 1

如果在建立 channel 時就知道要發送的值的個數,使用buffer就能夠簡化代碼。
仍然使用求平方數的例子,我們對 gen 函數進行重寫。我們將這組整型數拷貝到一個
緩衝 channel中,從而避免建立一個新的 goroutine:

func gen(nums ...int) <-chan int {    out := make(chan int, len(nums))    for _, n := range nums {        out <- n    }    close(out)    return out}

回到 流水線中被阻塞的 goroutine,我們考慮讓 merge 函數返回一個緩衝管道:

func merge(cs ...<-chan int) <-chan int {    var wg sync.WaitGroup    out := make(chan int, 1) // 在本例中儲存未讀的資料足夠了    // ... 其他部分代碼不變 ...

儘管這種方法解決了這個程式中阻塞 goroutine的問題,但是從長遠來看,它並不是好辦法。
緩衝大小選擇為1 是建立在兩個前提之上:

  1. 我們已經知道 merge 函數有兩個 inbound channel

  2. 我們已經知道下遊階段會消耗多少個值

這段代碼很脆弱。如果我們在傳入一個值給 gen 函數,或者下遊階段讀取的值變少,goroutine
會再次被阻塞。

為了從根本上解決這個問題,我們需要提供一種機制,讓下遊階段能夠告知上遊寄件者停止接收的訊息。
下面我們看下這種機制。

顯式取消 (Explicit cancellation)

當 main 函數決定退出,並停止接收 out 發送的任何資料時,它必須告訴上遊階段的 goroutine 讓它們放棄
正在發送的資料。 main 函數通過發送資料到一個名為 done 的channel實現這樣的機制。 由於有兩個潛在的
寄件者被阻塞,它發送兩個值。如下代碼所示:

func main() {    in := gen(2, 3)    // 啟動兩個運行 sq 的goroutine    // 兩個goroutine的資料均來自於 in    c1 := sq(in)    c2 := sq(in)    // 消耗 output 生產的第一個值    done := make(chan struct{}, 2)    out := merge(done, c1, c2)    fmt.Println(<-out) // 4 or 9    // 告訴其他寄件者,我們將要離開    // 不再接收它們的資料    done <- struct{}{}    done <- struct{}{}}

發送資料的 goroutine 使用一個 select 運算式代替原來的操作,select 運算式只有在接收到 out 或 done
發送的資料後,才會繼續進行下去。 done 的實值型別為 struct{} ,因為它發送什麼值不重要,重要的是它發送沒發送:
接收事件發生意味著 channel out 的發送操作被丟棄。 goroutine output 基於 inbound channel c 繼續執行
迴圈,所以上遊階段不會被阻塞。(後面我們會討論如何讓迴圈提前退出)。 使用 done channel 方式實現的merge 函數如下:

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {    var wg sync.WaitGroup    out := make(chan int)    // 為 cs 的的每一個 輸入channel    // 建立一個goroutine。output函數將    // 資料從 c 拷貝到 out,直到c關閉,    // 或者接收到 done 訊號;    // 然後調用 wg.Done()    output := func(c <-chan int) {        for n := range c {            select {            case out <- n:            case <-done:            }        }        wg.Done()    }    // ... the rest is unchanged ...

這種方法有一個問題:每一個下遊的接收者需要知道潛在被阻上遊寄件者的個數,然後向這些寄件者發送訊號讓它們提前退出。
時刻追蹤這些數目是一項繁瑣且易出錯的工作。

我們需要一種方式能夠讓未知數目、且個數不受限制的goroutine 停止向下遊發送資料。在Go語言中,我們可以通過關閉一個
channel 實現,因為在一個已關閉 channel 上執行接收操作(<-ch)總是能夠立即返回,傳回值是對應類型的零值。關於這點的細節,點擊這裡查看。

換句話說,我們只要關閉 done channel,就能夠讓解開對所有寄件者的阻塞。對一個管道的關閉操作事實上是對所有接收者的廣播訊號。

我們把 done channel 作為一個參數傳遞給每一個 流水線上的函數,通過 defer 運算式聲明對 done channel的關閉操作。
因此,所有從 main 函數作為源頭被調用的函數均能夠收到 done 的訊號,每個階段都能夠正常退出。 使用 done 對main函數重構以後,代碼如下:

func main() {    // 設定一個 全域共用的 done channel,    // 當流水線退出時,關閉 done channel    // 所有 goroutine接收到 done 的訊號後,    // 都會正常退出。    done := make(chan struct{})    defer close(done)    in := gen(done, 2, 3)    // 將 sq 的工作分發給兩個goroutine    // 這兩個 goroutine 均從 in 讀取資料    c1 := sq(done, in)    c2 := sq(done, in)    // 消費 outtput 生產的第一個值    out := merge(done, c1, c2)    fmt.Println(<-out) // 4 or 9    // defer 調用時,done channel 會被關閉。}

現在,流水線中的每個階段都能夠在 done channel 被關閉時返回。merge 函數中的 output 代碼也能夠順利返回,因為它知道 done channel關閉時,上遊寄件者 sq 會停止發送資料。 在 defer 運算式執行結束時,所有調用鏈上的 output 都能保證 wg.Done() 被調用:

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {    var wg sync.WaitGroup    out := make(chan int)    // 為 cs 的每一個 channel 建立一個 goroutine    // 這個 goroutine 運行 output,它將資料從 c    // 拷貝到 out,直到 c 關閉,或者 接收到 done    // 的關閉訊號。人啊後調用 wg.Done()    output := func(c <-chan int) {        defer wg.Done()        for n := range c {            select {            case out <- n:            case <-done:                return            }        }    }    // ... the rest is unchanged ...

同樣的原理, done channel 被關閉時,sq 也能夠立即返回。在defer運算式執行結束時,所有調用鏈上的 sq 都能保證 out channel 被關閉。代碼如下:

func sq(done <-chan struct{}, in <-chan int) <-chan int {    out := make(chan int)    go func() {        defer close(out)        for n := range in {            select {            case out <- n * n:            case <-done:                return            }        }    }()    return out} 

這裡,我們給出幾條構建流水線的指導:

  1. 當所有發送操作結束時,每個階段都關閉自己的 outbound channels

  2. 每個階段都會一直從 inbound channels 接收資料,直到這些 channels 被關閉,或寄件者解除阻塞狀態。

流水線通過兩種方式解除寄件者的阻塞:

  1. 提供足夠大的緩衝儲存寄件者發送的資料

  2. 接收者放棄 channel 時,顯式地通知寄件者。

結論

本文介紹了Go 語言中構建資料流水線的一些技巧。流水線的錯誤處理比較複雜,流水線的每個階段都可能阻塞向下遊發送資料,
下遊階段也可能不再關註上遊發送的資料。上面我們介紹了通過關閉一個channel,向流水線中的所有 goroutine 發送一個 "done" 訊號;也定義了
構建流水線的正確方法。

下一篇文章,我們將通過一個 並行 md5 的例子來說明本文所講的一些理念和技巧。

原作者 Sameer Ajmani,翻譯 Oscar

下期預告:Go語言並行存取模型:以並行md5計算為例。英文原文連結

相關連結

  1. 原文連結:https://blog.golang.org/pipel...

  2. Go並行存取模型:http://talks.golang.org/2012/...

  3. Go進階並行存取模型:http://blog.golang.org/advanc...

掃碼關注公眾號“深入Go語言”

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.