[翻譯]GO并發模型二:Pipeline和Cancellation

image.png

簡書不維護了,歡迎關注我的知乎:波羅學的個人主頁

緊接上文:[翻譯]GO并發模型一:Pipeline和Cancellation

明確地取消

當主函數在沒有從輸出channel中接收完所有值便退出時,它必須告訴上游停止數據發送。可以通過向done channel發送停止信號實現。此處有兩個可能阻塞的goroutine,所以需發兩個值。

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

    // Distribute the sq work across two goroutine that both read from in.
    c1 := sq(in)
    c2 := sq(in)

    // Consume the first value from output
    done := make(chan struct{}, 2)
    out := merge(done, c1, c2)
    fmt.Println(<-out)

    // Tell the remaining senders we're leaving
    done <- struct{}{}
    done <- struct{}{}
}

merge中的發送goroutine用select語句取代了原來的發送操作,它將負責將數據發出和接收done channel的消息。Done將接收的值是空結構體,因為該值沒有任何意義:它僅僅是用來表明應該停止向輸出channel發送數據了。該goroutine將會不停循環地從輸入channel中接收數據,以確保上游不被阻塞。(待會我們將會討論怎么提早從循環退出)。

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)
    
    // Start an output goroutine for each input channel in cs. output
    // copies values from c to out until c is closed or it receives a value
    // from done, then output calls 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上接收數據會被立刻處理并返回一個零值。

這意味著main函數中可僅僅通過關閉done channel來使發送方解除阻塞。該關閉操作會產生一個有效的廣播信號并傳遞給發送方。我們可以擴展pipeline中的函數,使其可以多接受一個done參數,然后通過defer語句對執行關閉以便于在main退出時發送給各階段完成信號來實現退出。

func main() {
    // Set up a done channel that's shared by the whole pipeline,
    // and close that channel when this pipeline exits, as a signal
    // for all the goroutines we started to exit.
    done := make(chan struct{})
    defer close(done)

    in := gen(done, 2, 3)

    // Distribute the sq work across two goroutines that both read from in.
    c1 := sq(done, in)
    c2 := sq(done, in)

    // Consume the first value from output.
    out := merge(done, c1, c2)
    fmt.Println(<-out) // 4 or 9

    // done will be closed by the deferred call.
}

一旦done channel關閉,各個階段就可以成功返回退出。當done被關閉,merge就會知道上游會停止發送數據,merge函數就會停止從輸入channel接收數據并返回。輸出channel通過defer語句確保所有的wg.Done在函數時能被調用。

func merge(done <-chan struct{}, cs ...<-chan int) <-chan int {
    var wg sync.WaitGroup
    out := make(chan int)

    // Start an output goroutine for each input channel in cs.  output
    // copies values from c to out until c or done is closed, then calls
    // 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語句,sql函數確保它們輸出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
}

pipeline幾個指導原則:

  • 各個階段在所有的發送操作完成便會關閉輸出channels;
  • 各個階段會不停的接收數據,直到這些channel都被關閉或者發送方不再阻塞;

Pipelines中可以通過為數據發送提供足夠的buffer大小或在接收方確定放棄繼續接收數據時發送完成信號來解除發送方的阻塞。

對目錄中的文件執行摘要

讓我們來看一個更實際的例子.

MD5是一種消息摘要算法,在checksum校驗文件方面非常有用。通過命令行工具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() {
    // Calculate the MD5 sum of all files under the specified directory,
    // then print the results sorted by path name.
    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.Sum。

// MD5All reads all the files in the file tree rooted at root and returns a map
// from file path to the MD5 sum of the file's contents.  If the directory walk
// fails or any read operation fails, MD5All returns an 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 nil, err
    }
    return m, nil
}

并行執行摘要

在parellel.go文件中,我們把MD5ALL拆成了兩階段。第一階段,在sumFiles函數中,它遍歷目錄并在各個goroutine中執行文件摘要,最后將結果發送給channel。

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

sumFiles函數返回了兩個channels:一個用于傳輸結果,另一個用于返回filepath.Walk的錯誤。walk函數為每個文件啟動了一個新的goroutine來處理它們,同時也檢查是否done 。如果done被關閉,walk函數將立刻返回。

func sumFiles(done <-chan struct{}, root string) (<-chan result, <-chan error) {
    // For each regular file, start a goroutine that sums the file and sends
    // the result on c.  Send the result of the walk on 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.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()
            }()
            // Abort the walk if done is closed.
            select {
            case <-done:
                return errors.New("walk canceled")
            default:
                return nil
            }
        })
        // Walk has returned, so all calls to wg.Add are done.  Start a
        // goroutine to close c once all the sends are done.
        go func() {
            wg.Wait()
            close(c)
        }()
        // No select needed here, since errc is buffered.
        errc <- err
    }()
    return c, errc
}

MD5All函數從c(channel)中接收摘要值。但發現錯誤,它會提早返回,并通過defer語句關閉done。

func MD5All(root string) (map[string][md5.Size]byte, error) {
    // MD5All closes the done channel when it returns; it may do so before
    // receiving all the values from c and 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文件中,我們創建了固定數量的goroutines來讀取文件。現在我們的pipeline涉及了三個階段:遍歷目錄樹、讀取文件并執行摘要和收集摘要結果。

第一階段,walkFiles,負責發送目錄樹中文件路徑:

func walkFiles(done <-chan struct{}, root string) (<-chan string, <-chan error) {
    paths := make(chan string)
    errc := make(chan error, 1)
    go func() {
        // Close the paths channel after Walk returns.
        defer close(paths)
        // No select needed for this send, since errc is buffered.
        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
}

第二階段,我們為digester函數啟動了固定數量的goroutine,它將從paths中接收文件名處理并發送摘要結果給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。取而代之,在所有digester執行完畢,MD5All會著手關閉channel。

    // Start a fixed number of goroutines to read and digest files.
    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,然后返回。但是這樣我們就需要增加額外的goroutines來對結果進行合并。

最后階段,我們從channel c中接收所有的結果并檢查errc是否返回了錯誤。該檢查無法過早執行,因為過早檢查,可能會導致walkFile阻塞。

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

總結

這篇文章給我們展示了如何在GO中構建流式數據pipeline。pipeline中處理失敗是需要一定的技巧的,因為每個嘗試給下游發送數據的階段都可能被阻塞,因為下游可能不在接收上游的輸入數據。我們展示了如何通過關閉channel來給所有的goroutine發送 "done" 信號和定義了正確構建pipeline的指導原則。

作者:Sameer Ajmani

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。