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爲sink
或consumer
下面從一個簡單的例子開始,之後還有其他相關的例子。
數字做平方
假設有一個包含三個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“廣播”消息以及正確的定義流水線。