Go并發模式:管道和終止

本文翻譯自Sameer Ajmani的文章《Go Concurrency Patterns: Pipelines and cancellation》。原文地址

gophers

介紹

Go語言的并發語意使得構建處理實時流式數據的pipeline非常方便,從而能夠有效地利用I/O和多核CPU。本文介紹了構建這種pipeline的一些例子,重點突出了操作失敗時的處理細節,并介紹了優雅處理故障的技術。

什么是pipeline?

Go語言對于pipeline沒有正式的定義;它只是眾多種并發程序之一。通俗地講,pipeline是通過channel連接一系列的階段,其中每個階段是運行相同函數的goroutine。在每一個階段,goroutine會

  • 通過入站channel接收來自上游的數據
  • 對該數據執行一些操作,通常會產生新的數據
  • 通過出站channel向下游發送數據

第一個階段只有出站channel,最后一個階段只有入站channel,除此之外,其它的階段都有任意數量的出站和入站channel。第一個階段有時候也被稱為source或者是producer,最后一個階段有時也被稱為sink或者是consumer。

我們將通過一個簡單的pipeline來解釋這樣的想法和技術,然后提出一個更有現實意義的例子。

算平方的例子

設想這樣的一個有三個階段的pipeline。

第一個階段為gen,這個函數將一個整數切片傳入一個channel,在所有的元素都傳入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。在入站channel被關閉,并且此階段已經向下游發送所有值后,關閉出站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運行最后一個階段,它接收來自第二階段的值,并逐個打印直到channel關閉。

func main() {
    c := gen(2, 3)
    out := sq(c)
    
    fmt.Println(<-out)
    fmt.Println(<-out)
}

// 4
// 9

因為sq的入站和出站channel類型一樣,我們可以多次組合使用它。比如下面的main函數

func main() {
    for n := range sq(sq(gen(2, 3))) {
        fmt.Println(n)
    }
}
//  16
//  81  

扇出,扇入

多個函數可以從同一個channel讀取數據,直到該channel被關閉:這被稱為扇出。扇出提供了一種分發任務,從而并行化使用CPU和I/O的方式。

將多個輸入channel復用到單個channel上,在所有輸入channel關閉時,關閉這個channel。通過這種方式,一個函數可以從多個輸入讀取數據,并執行相應的操作直到所有的數據源都被關閉。這被稱之為扇入。

我們可以改變上面的pipeline,運行兩個sq實例,每個實例從同一個輸入channel讀取數據。我們引入新的merge函數,來扇入結果。

func main() {
    in := gen(2, 3)
    
    // Distribute the sq work across two goroutines that both read from in.
    c1 := sq(in)
    c2 := sq(in)
    
    // Consume the merged output from c1 and c2.
    for n := range merge(c1, c2) {
        fmt.Println(n) // 4 then 9, or 9 then 4
    }
}

merge函數為每個入站channel啟動一個goroutine,將每個入站channel中的值拷貝到單個出站channel中。一旦所有的outputgoroutine啟動,merge函數會啟動一個新的goroutine,在出站channel接收所有值后將其關閉。

向一個已經關閉的channel傳入值會導致程序崩潰,所以在關閉channel前一定要保證所有的傳值操作都已完成。sync.WaitGroup類型提供了一種實現此類同步的簡單方法。

func merge(cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)
    
    // 為每個入站channel啟動一個名為output的goroutine,
    // output從每個channel中讀取值并傳入out,直到入站channel被關閉,然后調用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()
    }
    
    // 當所有的output 的goroutine都執行完成時,啟動一個新的goroutine來關閉out
    // 必須在調用wg.Add后執行這個操作。

    go func() {
        wg.Wait()
        close(out)
    }()
    return out
}

短停止

在上面的pipeline中有這樣的模式

  • 當所有的傳入操作完成時,關閉出站channel
  • 持續從入站channel接收數據,直到入站channel被關閉

此模式允許每個接收階段被寫成range循環,并確保所有goroutine在所有的值成功發送到下游后退出。

但是在實際的pipeline中,階段并不總是能接收所有入站的值。有時候這是設計決定的:接收端只需要部分數據。更常見的情況是,因為入站值的表示早期階段的錯誤,導致階段提前退出。不論哪種情況,接收器都不應該等待剩余的值到達,并且我們希望較早的階段停止產生后續階段不需要的值。

在上面的示例pipeline中,如果一個階段無法使用所有的入站值,那么嘗試發送這些值的goroutine將會無限期地阻塞。

// 接收output中第一個值
out := merge(c1, c2)
fmt.Println(<-out)
return

// 因為沒有接收out的第二個值,兩個output中的一個將會阻塞

這里存在著資源泄漏,goroutine消耗內存和運行時資源,而goroutine堆棧中的引用會阻止垃圾回收清理數據。goroutine的資源不能被自動垃圾回收;它們必須自己退出。

即使下游階段沒有收到所有的入站值,我們也需要讓pipeline的上游階段退出。一種實現方式是為出站channel添加緩沖區。緩沖區可以保存固定數量的值;如果緩沖區中有空間,立即完成發送操作。

c := make(chan int, 2)
c <- 1  // 執行成功
c <- 2  // 執行成功
c <- 3  // 阻塞住,直到另外一個goroutine做 <-c這樣的操作并且獲取1。

回到上面pipeline中的阻塞的goroutine,我們可以考慮在merge函數返回的出站channel中添加緩沖區。

func merge(cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int, 1)
    // ... 其余部分不變 ...
}

盡管修復了這個程序中的阻塞goroutine,這段代碼還是有問題。之所以將緩沖區大小設置為1,是因為我們知道merge接收值的數量,和下游階段會消耗值的數量。這是脆弱的:如果我們向gen多傳了一個值,或者下游階段讀取更少的值,程序中又將會出現阻塞的goroutine。

相反,我們需要為下游階段提供一種方法,向發送方指示讓它們停止接受輸入。

顯示取消

main函數決定退出,并且不再從out中接收值時,必須告訴上游階段的goroutine丟棄將要發送的值。它通過在名為done的channel上發送值來實現。它發送兩個值,因為有可能有兩個阻塞的發件人。

func main() {
    in := gen(2, 3)

    c1 := sq(in)
    c2 := sq(in)
    
    done := make(chan struct{}, 2)
    out := merge(done, c1, c2)
    fmt.Println(<-out)  // 4 或者 9
    
    done <- struct{}{}
    done <- struct{}{}
}

out上發生發送或者它們從done接收到值時,發送goroutine用select語句替換它們原有的發送操作。done的值類型是空結構體,因為值本身并不重要:它是一個接收事件,表示out上接收到的值應該被拋棄。

goroutineout繼續在入站channelc上循環,因此上游階段不會被阻塞(我們稍后將討論如何允許這個循環提前返回)。

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)
    
    // 為每個cs中的channel啟動一個output 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()
    }
    // ...其余代碼不變
}

這個方法有一個問題,每個下游接收器需要知道潛在阻塞的上游發送器的數量,并處理發送器提前返回的信號。跟蹤這些計數是乏味和容易出錯的。

我們需要一種方法,來告訴未知和無限數量的goroutine停止向下游發送它們的值。在Go中,我們可以通過關閉channel來做到這一點,因為在一個關閉的channel上的接收操作可以總是立即執行,產生元素類型的零值。

這意味著main可以通過關閉done channel來簡單解除所有發送者的阻塞。這個關閉操作時間是向發送者的廣播信號。我們擴展沒有pipeline函數,接收done作為參數,并通過defer語句來關閉它。

func main() {
    // 創建一個整個pipeline共享的channel
    // 在pipeline退出時,關閉這個channel
    // 同時作為信號讓啟動的所有goroutine退出
    done := make(chan struct{})
    defer close(done)
    
    in := gen(done, 2, 3)
    
    c1 := sq(done, in)
    c2 := sq(done, in)
    
    out := merge(done, c1, c2)
    fmt.Println(<-out) // 4或者9
    
    // done 會通過defer被關閉
}

現在pipeline中的每個階段都會在done被關閉后立即自由返回。merge中的output可以在不消耗其入站channel的情況下返回,因為它知道上游發送者sq會在done被關閉時停止發送。output通過defer關鍵字保證wg.Done在所有返回路徑上被調用。

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)
    
    output := func(c <-chan int) {
        defer wg.Done()
        for n := range c {
            select {
                case out <- n:
                case <-done:
                    return
            }
        }
    }
    
    // ... 其它代碼不變 ...
}

同樣的,sq也可以在done關閉時返回,通過defer保證out在所有返回路徑上被關閉。

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
}

以下是設計pipeline的指導方針。

  • 當所有的發送操作完成時,階段關閉它們的出站channel。
  • 階段持續從入站channel接收值,直到這些channel被關閉或者被解除阻塞

通過確保對所有發送的值有足夠的緩沖區或者通過在接收器放棄channel時顯式地發送信號通知發送方,pipeline可以解除發送方的阻塞。

摘要樹

讓我們來看看一個更有現實意義的pipeline。

MD5是一種消息摘要算法,可用作文件校驗。命令行程序md5sum會打印目錄中文件的摘要值。

% md5 *.go
MD5 (bounded.go) = e3635300581854a5dd4ae6f748b38775
MD5 (parallel.go) = 9efb4ffcca07e6994ef003a18925502a
MD5 (serial.go) = 26a2162e7cb28f4ed9f67e92616dbb24

我們的實例程序就像md5,它傳入單個目錄作為參數,并打印該目錄下每個常規文件的摘要值,按路徑名排序

% go run serial.go
go run serial.go .
e3635300581854a5dd4ae6f748b38775   bounded.go
9efb4ffcca07e6994ef003a18925502a   parallel.go
26a2162e7cb28f4ed9f67e92616dbb24   serial.go

程序的main函數調用MD5All函數,這個函數返回一個從路徑名到摘要值的映射,然后排序和打印結果

func main() {
    // 計算目錄下每個文件的MD5值
    // 按路徑名排序輸出結果
    m, err := MD5All(os.Args[1])
    if err != nil {
        fmt.Println(err)
        return
    }
    var paths []string
    for path := range m {
        paths = append(paths, path)
    }
    sort.Strings(paths)
    for _, path := range paths {
        fmt.Printf("%x  %s\n", m[path], path)
    }
}

函數MD5All將是我們討論的焦點,在serial.go中,實現不使用并發的方式,而是遍歷目錄下的問題,讀取并求出每個文件的MD5值。

func MD5All(root string) (map[string][md5.Size]byte, error) {
    m := make(map[string][md5.Size]byte)
    err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
        if err != nil {
            return err
        }
        if !info.Mode().IsRegular() {
            return nil
        }
        data, err := ioutil.ReadFile(path)
        if err != nil {
            return err
        }
        m[path] = md5.Sum(data)
        return nil
    })
    if err != nil {
        return nil, err
    }
    return m, nil
}

并行解法

parallel.go中,我們將MD5All函數拆成有兩個階段的pipeline。在第一個階段sumFiles中,程序遍歷目錄,為每個文件啟動一個goroutine來計算文件的MD5值,并將結果發送到一個類型為result的channel中。

type result struct {
    path string
    sum [md5.Size]byte
    err error
}

函數sumFiles返回兩個channel:一個用于results,另外一個用于filepath.Walk返回的錯誤信息。Walk函數啟動一個新的goroutine來處理每個常規文件,然后檢查done。如果done被關閉,Walk函數馬上停止。

func sumFiles(done <-chan struct{}, root string) (<-chan result, <-chan error) {
    c := make(chan result)
    errc := make(chan error, 1)
    go func() {
        var wg sync.WaitGroup
        err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
            if err != nil {
                return nil
            }
            if !info.Mode().IsRegular() {
                return nil
            }
            wg.Add(1)
            go func() {
                data, err := ioutil.ReadFile(path)
                select {
                case c <- result{path, md5.Sum(data), err}:
                case <-done:
                }
                wg.Done()
            }()
            select <-done{
                case <-done:
                    return errors.New("walk canceled")
                default:
                    return nil
            }
        })
        
        go func(){
            wg.Wait()
            close(c)
        }()
        errc <- err
    }()
    return c, errc
}

函數MD5Allc中接收結果,在發生錯誤時提前返回,通過defer關閉donechannel

func MD5All(root string) (map[string][md5.Size]byte, error) {
    done := make(chan struct{})
    defer close(done)
    
    c, errc := sumFiles(done, root)
    
    m := make(map[string][md5.Size]byte)
    for r := range c {
        if r.err != nil {
            return nil, r.err
        }
        m[r.path] = r.sum
    }
    if err := <-errc; err != nil {
        return nil, err
    }
    return m, nil
}

有界并行

parallel.go中,MD5All為每個文件都啟動了一個goroutine。在處理有很多大文件的文件夾時,這可能分配超過機器上可用上限的內存。

可以通過限制并行讀取文件的數量來限制占用的內存。在bounded.go中,通過創建固定數量的goroutine來讀取文件。現在的pipeline中有三個階段:遍歷目錄,讀取文件并計算摘要,收集摘要。

第一個階段,walkFiles,獲取目錄中的常規文件的路徑。

func walkFiles(done <-chan struct{}, root string) (<-chan string, <-chan error) {
    paths := make(chan string)
    errc := make(chan error, 1)
    go func() {
        defer close(paths)
        errc <- filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
            if err != nil {
                return err
            }
            if !info.Mode().IsRegular() {
                return nil
            }
            select {
            case paths <- path:
            case <-done:
                return errors.New("walk canceled")
            }
            return nil
        })
    }()
    return paths, errc
}

中間階段啟動固定數量的digestergoroutine,從paths中接收文件名并在channelc上發送結果。

func digester(done <-chan struct{}, paths <-chan string, c chan<- results) {
    for path := range paths {
        data, err := ioutil.ReadFile(path)
        select {
        case c <- result{path, md5.Sum(data), err}:
        case <-done:
            return
        }
    }
}

和之前的例子不同,digester不關閉其輸出channel,因為多個goroutine在共享channel上發送。在所有digester完成后,``MD5All````會關閉這個channel。

    c := make(chan result)
    var wg sync.WaitGroup
    const numDigesters = 20
    wg.Add(numDigesters)
    for i := 0; i < numDigesters; i++ {
        go func() {
            digester(done, paths, c)
            wg.Done()
        }()
    }
    go func() {
        wg.Wait()
        close(c)
    }()

可以讓每個digester創建和返回自己的輸出channel,但是這就需要額外的goroutine來扇出結果。

最后階段從c接收所有結果,然后檢查errc中的錯誤。此檢查不能發生地更早,因為在這之前,walkFiles可能阻塞向下游發送值。

    m := make(map[string][md5.Size]byte)
    for r := range c {
        if r.err != nil {
            return nil, r.err
        }
        m[r.path] = r.sum
    }
    
    if err := <-errc; err != nil {
        return nil, err
    }
    return m, nil

結論

本文介紹了在Go語言中構建數據流pipeline的技術。處理這樣的pipeline中出現的故障是棘手的,因為pipeline中的每個階段有可能會阻塞向下游發送值,并且下游階段可能不再關心輸入的數據。我們展示了如何通過關閉一個channel,向pipeline中啟動的所有goroutine廣播一個“完成”信號,并且定義了正確構造pipeline的指南。

“本譯文僅供個人研習、欣賞語言之用,謝絕任何轉載及用于任何商業用途。本譯文所涉法律后果均由本人承擔。本人同意簡書平臺在接獲有關著作權人的通知后,刪除文章?!?/strong>

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,797評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,179評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 175,628評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,642評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,444評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,948評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,040評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,185評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,717評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,602評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,794評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,045評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,418評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,671評論 1 281
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,414評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,750評論 2 370

推薦閱讀更多精彩內容