Go併發模型:流水線與取消(Pipelines and cancellation譯文)

Go併發模型:流水線與取消 (Go Concurrency Patterns: Pipelines and cancellation)

本文不只是簡單的翻譯,有些地方根據自己的理解用中文習慣重新組織了語言,所以可能會有局部的順序不同,但是讀起來更通順。所以如果對文中任何部分有疑問可以直接體溫,保證知無不言。

英文原版: https://blog.golang.org/pipelines

簡介

go語言的併發機制可以使CPU及IO更高效的處理數據流。本文展示幾個例子來介紹下流水線以及執行操作失敗時的細節,還有處理異常時所用的技術。

流水線是啥

在go裏面並沒有對流水線的正式定義,它就是各種併發程序。通俗來說,一個流水線就是通過channel連接的一組stage,每個stage就是運行着同一function的一組goroutine。這些goroutine完成如下任務

  • 從已綁定的輸入channel中讀取上游數據
  • 處理讀到的數據,通常會產生新的數據
  • 將新的數據發送到已綁定的輸出channel

第一個stage只有輸入channel,最後一個stage只有輸出channel,其他每個stage都有若干個輸入輸出channel。第一個stage有時被稱爲srouce或者producer,稱最後一個stage爲sinkconsumer

下面從一個簡單的例子開始,之後還有其他相關的例子。

數字做平方

假設有一個包含三個stage的流水線。

第一個stage是一個叫gen的function,它把參數中傳進來的數字list在goroutine中放進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
}

第二個stage是個叫sq的function,它從一個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方法,建立起流水線並充當第三個stage。即從channel中讀取數字並挨個print:

func main() {
    // 建立流水線
    c := gen(2, 3)
    out := sq(c)
    
    // print結果
    fmt.Println(<-out)
    fmt.Println(<-out)
}

由於sq的輸入跟輸出是同類型的channel,所以我們可以讓它嵌套一下。當然這個main也可以寫成上面兩個stage風格:

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

扇入(fan-in),扇出(fan-out)

扇入指的是在一個function中處理多個輸入channel並將結果輸出到一個輸出channel,並在所有輸入channel關閉後關閉輸出channel。扇出就是一個channel可以作爲多個function的輸入channel,這就相當於有多個worker分發處理同一組任務。現在我們來調整一下流水線,讓平方操作分發給兩個sq實例,當然所以sq實例共享同一個輸入channel。然後我們還需要一個新的function來扇入這些數據,就叫merge吧。首先調整一下main:

func main() {
    in := gen(2, 3)
    
    c1 := sq(in)
    c2 := sq(in)
    
    for n := range merge(c1, c2) {
        fmt.Println(n)
    }
}

merge方法中給每個輸入channel起個goroutine來讀數據,然後放進同一個輸出channel,另外還需要一個goroutine等所有的輸入channel關閉後關閉輸出channel

func merge(cs ...<-chan int) <-chan int {
    // 用來等待輸入channel的wg
    var wg sync.WaitGroup
    // 唯一的輸出channel
    out := make(chan int)
    
    // 將處理輸入的func定義成變量
    output := func(c <-chan int) {
        for n := range c {
            out <- n
        }
        wg.Done()
    }
    // 等待數跟輸入channel數一樣
    wg.Add(len(cs))
    // 爲每個輸入channel開goroutine
    for _, c := range cs {
        go output(c)
    }
    
    // 開goroutine進行等待與關閉輸出channel
    go func() {
        wg.Wait()
        close(out)
    }()
    // 返回輸出channel
    return out
}

急停(stoping short)

上面離職中的流水線有如下兩個模式:

  • stage執行完所有的發送操作後關閉輸出channel
  • stage在輸入channel關閉前持續讀取數據

這種模式允許每個stage在接收數據時可以用range循環,並且保證每個goroutine在將所有的數據成功發送給下游之後立即退出。但是實際用到的流水線,stage不一定總是接收所有的輸入數據,這跟設計有關。例如有時候stage只需要輸入數據的一部分子集就可以完成任務。很多時候stage還會提前退出,比如上游的stage傳進來一個error。還有中情況是stage已經不需要再接收數據了,並且這種情況下還希望上游stage不在繼續產生下游不需要的數據。在上面的例子中,如果有個stage出錯不能在消費輸入channel的數據,那麼這個channel的發送端將會永久阻塞:

// 消費中的第一條數據
out := merge(c1, c2)
fmt.Println(<-out)
return
// 從此不再消費第二條及之後的數據,這樣想往out發數據的地方就會阻塞

這顯然會導致協程泄露,goroutine使用的cpu等運行時資源,還有它自己的堆棧裏的數據也不會被GC回收。注意goroutine是沒有回收機制的,他們只能自己退出。如果pipeline中的下游stage不能再繼續消費數據,那麼我們需要讓上游的stage知道並作出響應(退出或其他操作)。我們可以給channel加緩衝區,當緩衝區還有空間的時候向channel發送數據就不會阻塞:

c := make(chan int, 2)
c <- 1 // 直接發送
c <- 2 // 直接發送
c <- 3 // 如果channel另一頭沒消費前面的數據,這裏會一直阻塞,直到緩衝區有空位

如果可以事先直到要處理的數據量,那就可以直接用帶緩衝區的channel來簡化代碼。拿上面的例子來說,現在可以直接重寫gen方法,有了緩衝區甚至可以不開新的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 {
   // 只展示被修改這行代碼,其他地方不動
   //out := make(chan int)
   out := make(chan int, 1) // 在這裏寫個足夠大的值來存放沒有被消費的數據
}

雖然這樣做“解決了”goroutine阻塞的問題,但這其實不是正解。這裏設置的緩衝區的大小“1”依賴於已知merge方法能接到多少數據以及下游的stage能消費多少。這在健壯性上差點意思,如果給gen加了點可選數據,或者說下游需要的數據變少時,仍然會有goroutine阻塞。所以我們需要找到一種方法讓下游的stage可以把自己準備停止消費數據的消息“告訴”上游。

明確取消(explicit cancellation)

當main決定不接受輸入channel的剩餘數據時,它必須通知上游的stage來丟棄還沒發送的數據。可以通過加一個channel來實現,可以稱它爲done,由於上游有兩個發送方,所以需要給done發兩個數據:

func main() {
    in := gen(2,3)
    c1 := sq(in)
    c2 := sq(in)
    
    done := make(chan interface{}, 2)
    out := merge(done, c1, c2)
    fmt.Println(<-out)
    
    // 通知發送方
    done <- struct{}{}
    done <- struct{}{}
}

然後他的發送方需要用select來調整一下邏輯,select中用兩個case分別對應向out發數據以及從done收數據。done中的數據類型爲空struct是因爲其值不需要被關心,起作用的是有或者沒有。如下修改之後,這個output所在的goroutine就可以繼續for循環,就不會阻塞它的上游stage了。(後面會繼續說怎麼讓這個循環早點退出,畢竟下游都不要數據了,這裏空跑也沒意義)

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
    // 其他代碼不變,這裏只寫output這段
    output := func(c <-chan int) {
        for n := range c {
            select {
            case out <- n:
            case <-done:
            }
        }
        wg.Done()
    }
}

這種解決辦法有個弊端:每個下游的接收方都需要知道會被它阻塞的上游發送方的數量,以及挨個給他們發送結束的信號,持續維護這個數目比較蛋疼而且容易出錯。因此需要一種方式來告知不可預知的goroutine來停止發送數據,在go裏可以通過關閉channel來實現。因爲從已關閉的channel讀數據會立即讀到一個該類型的零值數據。這就意味着main可以通過關閉done通道來非阻塞的通知所有上游,這樣一來關閉操作就成了非常高效的廣播。現在可以擴展一下流水線裏的每個function,讓他們多接受一個done參數,然後在main中通過defer來調用close,這樣main無論在什麼情況下退出都會向上遊stage發送退出信號。

func main() {
    // 上面說的比較清楚了,這裏不放註釋了
    done := make(chan struct{})
    defer close(done)
    
    in := gen(done, 2, 3)
    
    c1 := sq(done, in)
    c2 := sq(doen, in)
    
    out := merge(done, c1, c2)
    fmt.Println(<-out)
}

然後再調整一下merge,這次放上完整的代碼

func merge(cs ...<-chan int) <-chan int {
    // 用來等待輸入channel的wg
    var wg sync.WaitGroup
    // 唯一的輸出channel
    //out := make(chan int)
    out := make(chan int, 1) // 在這裏寫個足夠大的值來存放沒有被消費的數據
    
    // 將處理輸入的func定義成變量
    output := func(c <-chan int) {
        // 本次改動:done以defer形式調用,放在函數開頭
        defer wg.Done()
        for n := range c {
            select {
            case out <- n:
            case <-done:
                // 本次改動:加上return,done之後直接退出
                return
            }
        }
    }
    // 等待數跟輸入channel數一樣
    wg.Add(len(cs))
    // 爲每個輸入channel開goroutine
    for _, c := range cs {
        go output(c)
    }
    
    // 開goroutine進行等待與關閉輸出channel
    go func() {
        wg.Wait()
        close(out)
    }()
    // 返回輸出channel
    return out
}

上面代碼中做了“本次改動”之後就可以在收到done消息時結束方法,並調用waitGroup的done方法。現在整個流水線中的每個stage都是獨立的自由退出的(即收到done被關閉消息時直接退出)。同時merge中的每個output所在goroutine,可以在收到close消息時不管上游數據是否還有剩餘就直接退出。

類似的sq方法也可以在收到close消息時立即退出,並且可以通過defer來保證關閉輸出channel:

func sq(done <-chan struct{}, int <-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
}

關於流水線有這樣兩條規則:

  • stage在所有的數據發送完成之後,關閉輸出channel
  • stage應該持續從輸入channel接收數據,除非這些channel關閉或者發送方是非阻塞式的。(非阻塞式的發送方:比如發了10條數據,channel的緩衝區也爲10)

總的來說,流水線中讓發送方stage不阻塞的方式有兩種,一個是讓channel有足夠的buffer,另一個就是接收方顯示通知發送方不需要繼續發數據了。

多路處理

再來看一個更實際的流水線。

MD5是是一種摘要算法,文件校驗和通常用MD5。md5sum這個命令可以同時處理多個文件:

% md5sum *.go
d47c2bbc28298ca9befdfbc5d3aa4e65  bounded.go
ee869afd31f83cbb2d10ee81b2b831dc  parallel.go
b88175e65fdcbc01ac08aaf1fd9b5e96  serial.go

這裏用的例子跟md5sum差不多,但接收的參數是一個目錄,然後打印目錄下的每個文件的摘要值,並且按名稱排序。

% go run serial.go .
d47c2bbc28298ca9befdfbc5d3aa4e65  bounded.go
ee869afd31f83cbb2d10ee81b2b831dc  parallel.go
b88175e65fdcbc01ac08aaf1fd9b5e96  serial.go

main方法中用到的是MD5All這個方法,它可以返回一個map,文件名作爲key,摘要作爲value:

func main() {
    // 計算路徑下所有文件的sum,然後按文件名排序輸出
    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)
    }
}

先來看下serial.go中的MD5All,這裏是沒用到併發的,只是簡單的挨個讀文件然後計算:

// MD5All讀取root下所有文件,返回一個map,map的key是文件名,value是文件的md5。如果發生錯誤會返回一個error
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 nik, err
    }
    return m, nil
}

並行處理

而在parallel.go文件中將MD5All拆分成了包含倆stage的流水線。第一個stage叫sumFiles,用來遍歷併爲每個文件開新的goroutine來計算摘要,然後將結果發給輸出channel。先看下這個result的定義:

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

sumFiles返回兩個channel,一個用來傳遞result,另一個傳遞filepath.Walk產生的error。walk裏面會給每個文件開新的goroutine來讀文件與計算摘要,然後檢查done這個channel。如果done關閉了,walk會立即停止:

func sumFiles(done <-chan struct{}, root string) (<-chan result, <-chan error) {
    // 給每個常規文件開獨立的goroutine來處理,之後把result發送到c這個channel
    // walk產生的error發送到errc這個channel
    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.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()
            }()
            // 如果done已經close了,終止walk這個方法
            select {
                case <-done:
                    return errors.New("walk canceled")
                delfault:
                    return nil
            }
        })
        // 代碼運行到這,說明walk方法已經return了,也就意味着所有的`wg.Add(1)`都執行了。因此在這裏開一個goroutine來執行等待,就可以保證在所有的`wg.Done()`執行後再關閉c這個channel
        go func() {
            wg.Wait()
            close(c)
        }()
        // 這裏不用select,因爲errc定義時就給了大小爲1的緩衝區
        errc <- err
    }()
    return c, errc
}

MD5All從c這個channel中接收摘要結果,如果有error會提前return。done這個channel的關閉時通過defer的方式執行的:

func MD5All(root string) (map[string][md5.Size]byte, error) {
    // done會在方法return時關閉,方法返回時可能並沒有處理完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 nik, r.err
        }
        m[r.path] = r.sum
    }
    if err := <-errc; err != nil {
        return nil, err
    }
    return m, nil
}

有限並行(Bounded parallelism)

上面帶並行的MD5All方法給每個文件都開goroutine去處理,但是如果這個文件中有很多大文件的話,這可能會比較耗內存。所以需要限制並行處理的文件數量。在bounded.go中設置了一個變量來作爲goroutine數最大值,這樣一來流水線就成了3個stage:遍歷文件、讀文件與計算摘要、彙總摘要。第一個stage負責產出文件path:

func walkFiles(done <-chan struct{}, root string) (<-chan string, <-chan error) {
    pahts := 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.Mode().IsRegular() {
                return nil
            }
            select {
                case paths <- path:
                case <-done:
                    return errors.New("walk canceled")
            }
            return nil
        })
    }()
    return paths, errc
}

第二個stage是預設數量的goroutine來從paths通道讀取文件路徑,處理之後將result從c通道發送出去,這個stage可以命名爲digester

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

digester跟之前的例子不一樣,它不會主動關閉輸出channel。這個channel是多個goroutine在同時向其發送數據的。所以它是在MD5All中將其中數據消費之後再關閉的:

// 被`digester`們共享的輸出channel
c := make(chan result)
// 用來等待所有的`digester`完成
var wg sync.WaitGroup
// 用來指定`digester`的最大數量
const numDigesters = 20
wg.Add(numDigesters)
// 啓動指定數量的`digester`
for i := 0; i < numDigesters; i++ {
    go func() {
        digester(done, paths, c)
        wg.Done()
    }()
}
// 在所有digester完成之後關閉共享通道
go func() {
    wg.Wait()
    close(c)
}

實際上也可以讓每個digester創建獨立的輸出channel,但這樣的話就需要再加一個goroutine來扇入(fan-in)這些輸出channel了。最後一個stage就是從c這個共享channel中讀取所有的result,以及檢查errc裏的error。這個檢查操作不會提前執行,因爲這樣的話walkFiles可能會阻塞下游的stage。

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語言中構建數據流水線,在流水線中每個stage處理異常可能會阻塞下游的stage,下游的stage也有可能不需要後續的上游數據。文中介紹了通過關閉done通道來向所有stage“廣播”消息以及正確的定義流水線。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章