Go併發模式:管道和取消

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

原地址:http://air.googol.im/2014/03/15/go-concurrency-patterns-pipelines-and-cancellation.html

譯自http://blog.golang.org/pipelines。

這是Go官方blog的一篇文章,介紹了如何使用Go來編寫並發程式,並按照程式的演化順序,介紹了不同模式遇到的問題以及解決的問題。主要解釋了用管道模式連結不同的線程,以及如何在某個線程取消工作時,保證所有線程以及管道資源的正常回收。

Go併發模式:管道和取消

作者:Sameer Ajmani,blog.golang.org,寫於2014年3月13日。

介紹

Go本身提供的並發特性,可以輕鬆構建用於處理流資料的管道,從而高效利用I/O和多核CPU。這篇文章就展示了這種管道的例子,並關注當操作失敗時要處理的一些細節,並介紹了如何乾淨的處理錯誤的技巧。

什麼是管道?

Go語言裡沒有明確定義管道,而只是把管道當作一類並發程式。簡單來說,管道是一系列由channel聯通的狀態(stage),而每個狀態是一組運行相同函數的Goroutine。每個狀態上,Goroutine

  • 通過流入(inbound)channel接收上遊的數值
  • 運行一些函數來處理接收的資料,一般會產生新的數值
  • 通過流出(outbound)channel將數值發給下遊

每個語態都會有任意個流入或者流出channel,除了第一個狀態(只有流出channel)和最後一個狀態(只有流入channel)。第一個狀態有時被稱作源或者生產者;最後一個狀態有時被稱作槽(sink)或者消費者。

我們先從一個簡單的管道例子開始解釋這些想法和技術。之後,我們再來看一些更真實的例子。

求平方數

考慮一個管道和三個狀態。

第一個狀態,gen,是一個將一系列整數一一傳入channel的函數。gen函數啟動一個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被關閉,而且狀態已經把所有數值都發送給了下遊,關閉流出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}

主函數建立起管道,並執行最終的狀態:從第二個狀態接收所有的數值並列印,直到channel被關閉:

func main() {    // 建立管道    c := gen(2, 3)    out := sq(c)    // 產生輸出    fmt.Println(<-out) // 4    fmt.Println(<-out) // 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。這種情況稱作扇入

我們可以將我們的管道改為同時執行兩個sq執行個體,每個都從同樣的輸入channel讀取資料。我們還引入新函數,merge,來扇入所有的結果:

func main() {    in := gen(2, 3)    // 在兩個從in裡讀取資料的Goroutine間分配sq的工作    c1 := sq(in)    c2 := sq(in)    // 輸出從c1和c2合并的資料    for n := range merge(c1, c2) {        fmt.Println(n) // 4 和 9, 或者 9 和 4    }}

merge對每個流入channel啟動一個Goroutine,並將流入的數值複製到流出channel,由此將一組channel轉換到一個channel。一旦啟動了所有的output Goroutine,merge函數會多啟動一個Goroutine,這個Goroutine在所有的輸入channel輸入完畢後,關閉流出channel。

往一個已經關閉的channel輸出會產生異常(panic),所以一定要保證所有資料發送完成後再執行關閉。sync.WaitGroup類型提供了方便的方法,來保證這種同步:

func merge(cs ...<-chan int) <-chan int {    var wg sync.WaitGroup    out := make(chan int)    // 為cs中每個輸入channel啟動輸出Goroutine。output從c中複製數值,直到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都工作完後(wg.Done),關閉out,    // 保證只關閉一次。這個Goroutine必須在wg.Add之後啟動    go func() {        wg.Wait()        close(out)    }()    return out}

突然關閉

我們的管道函數裡有個模式:

  • 狀態會在所有發送操作做完後,關閉它們的流出channel
  • 狀態會持續接收從流入channel輸入的數值,直到channel關閉

這個模式使得每個接收狀態可以寫為一個range迴圈,並保證所有的Goroutine在將所有的數值發送成功給下遊後立刻退出。

但是實際的管道,狀態不能總是接收所有的流入數值。有時這是設計決定的:接收者可能只需要一部分數值做進一步處理。更常見的情況是,一個狀態會由於從早先的狀態流入的數值有誤而退出。不管哪種情況,接收者都不應該繼續等待剩下的數值,而且我們希望早先的狀態可以停止生產後續狀態不需要的資料。

在我們的管道例子裡,如果一個狀態無法處理所有的流入數值,試圖發送那些數值的Goroutine會被永遠阻塞住:

    // 處理輸出的第一個數值    out := merge(c1, c2)    fmt.Println(<-out) // 4 或者 9    return    // 由於我們不再接收從out輸出的第二個數值,其中一個輸出Goroutine會由於試圖發送數值而掛起}

這是資源泄漏:Goroutine會佔用記憶體和運行時資源,而且Goroutine棧裡的堆引用會一直持有資料,這些資料無法被記憶體回收。Goroutine本身也無法被記憶體回收,它們必須靠自己退出(而不是被其他人殺死)。

即便下遊的狀態無法接收所有的流入數值,我們依然需要讓管道裡的上遊狀態正常退出。一種方法是修改流出channel,使其含有緩衝區。緩衝區可以持有固定數量的數值,當緩衝區有空間時,發送操作會立刻完成(不會產生阻塞)。

在建立channel時,如果已經知道要發送數值的數量,緩衝區可以簡化代碼。比如,我們可以讓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}

回到我們管道的阻塞問題上來,我們可以考慮給merge的流出channel加上緩衝區:

func merge(cs ...<-chan int) <-chan int {    var wg sync.WaitGroup    out := make(chan int, 1) // 1個空間足夠應付未讀的輸入    // ... 其餘未變 ...

這個改動當然修正了程式中阻塞Goroutine的問題,但這不是好的代碼。緩衝區的大小為1,依賴於我們已經知道我們將要merge的數值總數和下遊狀態要處理的數值總數。這太脆弱了:如果我們從gen傳入額外的數值,或者下遊狀態再多讀一些數值,我們仍將看到Goroutine被阻塞住了。

不使用緩衝區的話,我們需要提供一種方法,讓下遊狀態通知寄件者,下遊狀態將停止接收輸入。

明確的取消

main要在不接收所有來自out的數值前退出,就需要告訴所有上遊狀態的Goroutine,放棄嘗試發送數值的行為。這可以通過發送數值到一個叫做done的channel來完成。例子裡有兩個潛在的會被阻塞的寄件者,所以給done發送了兩個數值:

func main() {    in := gen(2, 3)    // 發布sq的工作到兩個都從in裡讀取資料的Goroutine    c1 := sq(in)    c2 := sq(in)    // 處理來自output的第一個數值    done := make(chan struct{}, 2)    out := merge(done, c1, c2)    fmt.Println(<-out) // 4 或者 9    // 通知其他寄件者,該退出了    done <- struct{}{}    done <- struct{}{}}

發送Goroutine將發送操作替換為一個select語句,要麼把資料發送給out,要麼處理來自done的數值。done的類型是個空結構,因為具體數值並不重要:接收事件本身就指明了應當放棄繼續發送給out的動作。而output Goroutine會繼續迴圈處理流入的channel,c,而不會阻塞上遊狀態:

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {    var wg sync.WaitGroup    out := make(chan int)    // 為每個cs中的輸入channel啟動一個output Goroutine。outpu從c裡複製數值直到c被關閉    // 或者從done裡接收到數值,之後output調用wg.Done    output := func(c <-chan int) {        for n := range c {            select {            case out <- n:            case <-done:            }        }        wg.Done()    }    // ... 其餘的不變 ...

但是這種方法有個問題:下遊的接收者需要知道潛在會被阻塞的上遊寄件者的數量。追蹤這些數量不僅枯燥,還容易出錯。

我們需要一種方法,讓不知道也不限制數量的Goroutine,停止往它們下遊發送資料的行為。在Go裡,我們可以通過關閉channel來實現這個工作,因為channel被關閉時,接收工作會立刻執行,併產生一個符合類型的0值。

這就是說,main可以容易的通過關閉donechannel來釋放所有的寄件者。關閉是個高效的發送給所有寄件者的廣播訊號。我們擴充管道裡的每個函數,讓其以參數方式接收done,並通過defer語句在函數退出時執行關閉操作,這樣main裡所有的退出路徑都會觸發管道裡的所有狀態退出。

func main() {    // 構建done channel,整個管道裡分享done,並在管道退出時關閉這個channel    // 以此通知所有Goroutine該推出了。    done := make(chan struct{})    defer close(done)    in := gen(done, 2, 3)    // 發布sq的工作到兩個都從in裡讀取資料的Goroutine    c1 := sq(done, in)    c2 := sq(done, in)    // 處理來自output的第一個數值    out := merge(done, c1, c2)    fmt.Println(<-out) // 4 或者 9    // done會通過defer調用而關閉}

管道裡的每個狀態現在都可以隨意的提早退出了:sq可以在它的迴圈中退出,因為我們知道如果done已經被關閉了,也會關閉上遊的gen狀態。sq通過defer語句,保證不管從哪個返迴路徑,它的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}

下面列出了構建管道的指南:

  • 狀態會在所有發送操作做完後,關閉它們的流出channel
  • 狀態會持續接收從流入channel輸入的數值,直到channel關閉或者其寄件者被釋放。

管道要麼保證足夠能存下所有發送資料的緩衝區,要麼接收來自接收者明確的要放棄channel的訊號,來保證釋放寄件者。

對目錄做摘要

來考慮一個更現實的管道。

MD5是一個摘要演算法,經常在對檔案的校正的時候使用。命令列上使用md5sum來列印出一系列檔案的摘要數值。

我們的程式類似md5sum,但是參數是一個目錄,之後會列印出這個目錄下所有常規檔案的摘要值,以檔案路徑名排序。

我們的主函數包含一個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檔案裡,是非並發的函數實現,再掃描分類樹時簡單讀取並計算每個檔案。

// MD5All讀取檔案目錄root下所有檔案,並返回從檔案路徑到檔案內容MD5值的映射。如果掃描目錄// 出錯或者任何操作失敗,MD5All返回失敗。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.IsDir() {            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分解為兩個狀態的管道。第一個狀態,sumFiles,遍曆目錄,在一個新的Goroutine裡對每個檔案做摘要,並把結果發送到類型為result的channel:

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

sumFiles返回兩個channel:一個用來傳遞result,另一個用來返回filepath.Walk的錯誤。遍曆函數啟動一個新的Goroutine來處理每個常規檔案,之後檢查done。如果done已經被關閉了,遍曆就立刻停止:

func sumFiles(done <-chan struct{}, root string) (<-chan result, <-chan error) {    // 對每個常規檔案,啟動一個Goroutine計算檔案內容並發送結果到c。發送walk的結果到errc    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 err            }            if info.IsDir() {                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()            }()            // 如果done被關閉了,停止walk            select {            case <-done:                return errors.New("walk canceled")            default:                return nil            }        })        // walk已經返回,所有wg.Add的工作都做完了。開啟新進程,在所有發送完成後        // 關閉c。        go func() {            wg.Wait()            close(c)        }()        // 因為errc有緩衝區,所以這裡不需要select。        errc <- err    }()    return c, errc}

MD5Allc接收所有的摘要值。MD5All返回早先的錯誤,通過defer關閉done

func MD5All(root string) (map[string][md5.Size]byte, error) {    // MD5All在返回時關閉done channel;這個可能在從c和errc收到所有的值之前被調用    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,來限制記憶體使用量。現在整個管道有三個狀態:遍曆樹,讀取並對檔案做摘要,收集摘要值。

第一個狀態,walkFiles,發送樹裡的每個常規檔案的路徑:

func walkFiles(done <-chan struct{}, root string) (<-chan string, <-chan error) {    paths := make(chan string)    errc := make(chan error, 1)    go func() {        // 在Walk之後關閉paths channel        defer close(paths)        // 因為errc有緩衝區,所以這裡不需要select。        errc <- filepath.Walk(root, func(path string, info os.FileInfo, err error) error {            if err != nil {                return err            }            if info.IsDir() {                return nil            }            select {            case paths <- path:            case <-done:                return errors.New("walk canceled")            }            return nil        })    }()    return paths, errc}

中間的狀態啟動固定數量的digester Goroutine,從paths接收檔案名,並將結果result發送到channel c

func digester(done <-chan struct{}, paths <-chan string, c chan<- result) {    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。另一邊,MD5All中的代碼會在所有digester完成後關閉channel:

    // 啟動固定數量的Goroutine來讀取並對檔案做摘要。    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收集到所有結果result,並檢查從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    }    // 檢查Walk是否失敗    if err := <-errc; err != nil {        return nil, err    }    return m, nil}

結論

這篇文章展示了使用Go構建流資料管道的技術。要謹慎處理這種管道產生的錯誤,因為管道裡的每個狀態都可能因為向下遊發送數值而阻塞,而下遊的狀態卻不再關心輸入的資料。我們展示了如何將關閉channel作為“完成”訊號廣播給所有由管道啟動的Goroutine,並且定義了正確構建管道的指南。

進一步閱讀:

Go併發模式(視頻)展示了Go的並發特性的基礎知識,並示範了應用這些知識的方法。
進階Go併發模式(視頻)覆蓋了關於Go特性更複雜的使用情境,尤其是select。
Douglas McIlroy的論文《一窺級數數列》展示了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.