本文翻譯自Sameer Ajmani的文章《Go Concurrency Patterns: Pipelines and cancellation》。原文地址
介紹
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中。一旦所有的output
goroutine啟動,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
}
函數MD5All
從c
中接收結果,在發生錯誤時提前返回,通過defer
關閉done
channel
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
}
中間階段啟動固定數量的digester
goroutine,從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>