Go併發模式:管道和顯式取消

Codefor發表於2014-04-25

引言

Go併發原語使得構建流式資料管道,高效利用I/O和多核變得簡單。這篇文章介紹了幾個管道例子,重點指出在操作失敗時的細微差別,並介紹了優雅處理失敗的技術。

什麼是管道?

Go沒有正式的管道定義。管道只是眾多併發程式的一類。一般的,一個管道就是一些列的由channel連線起來的階段。每個階段都有執行相同邏輯的goroutine。在每個階段中,goroutine

  • 從channel讀取上游資料
  • 在資料上執行一些操作,通常會產生新的資料
  • 通過channel將資料發往下游

每個階段都可以有任意個輸入channel和輸出channel,除了第一個和最有一個channel(只有輸入channel或只有輸出channel)。第一個步驟通常叫資料來源或者生產者,最後一個叫做儲存池或者消費者。

我們先從一個簡單的管道例子來解釋這些概念和技術,稍後我們會介紹一個更為複雜的例子。

數字的平方

假設管道有三個階段。

第一步,gen函式,是一個將數字列表轉換到一個channel中的函式。Gen函式啟動了一個goroutine,將數字傳送到channel,並在所有數字都傳送完後關閉channel。

第二個階段,sq,從上面的channel接收數字,並返回一個包含所有收到數字的平方的channel。在上游channel關閉後,這個階段已經往下游傳送完所有的結果,然後關閉輸出channel:

main函式建立這個管道,並執行第一個階段,從第二個階段接收結果並逐個列印,直到channel被關閉。

因為sq對輸入channel和輸出channel擁有相同的型別,我們可以任意次的組合他們。我們也可以像其他階段一樣,將main函式重寫成一個迴圈遍歷。

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

多個函式可以從同一個channel讀取資料,直到這個channel關閉,這叫扇出。這是一種多個工作例項分散式地協作以並行利用CPU和I/O的方式。

一個函式可以從多個輸入讀取並處理資料,直到所有的輸入channel都被關閉。這個函式會將所有輸入channel匯入一個單一的channel。這個單一的channel在所有輸入channel都關閉後才會關閉。這叫做扇入。

我們可以設定我們的管道執行兩個sq例項,每一個例項都從相同的輸入channel讀取資料。我們引入了一個新的函式,merge,來扇入結果:

merge函式為每一個輸入channel啟動一個goroutine,goroutine將資料拷貝到同一個輸出channel。這樣就將多個channel轉換成一個channel。一旦所有的output goroutine啟動起來,merge就啟動另一個goroutine,在所有輸入拷貝完畢後關閉輸出channel。
向一個關閉了的channel傳送資料會觸發異常,所以在呼叫close之前確認所有的傳送動作都執行完畢很重要。sync.WaitGroup型別為這種同步提供了一種簡便的方法:

停止的藝術

我們所有的管道函式都遵循一種模式:

  • 傳送者在傳送完畢時關閉其輸出channel。
  • 接收者持續從輸入管道接收資料直到輸入管道關閉。

這種模式使得每一個接收函式都能寫成一個range迴圈,保證所有的goroutine在資料成功傳送到下游後就關閉。

但是在真實的案例中,並不是所有的輸入資料都需要被接收處理。有些時候是故意這麼設計的:接收者可能只需要資料的子集就夠了;或者更一般的,因為輸入資料有錯誤而導致接收函式提早退出。上面任何一種情況下,接收者都不應該繼續等待後續的資料到來,並且我們希望上游函式停止生成後續步驟已經不需要的資料。

在我們的管道例子中,如果一個階段無法消費所有的輸入資料,那些傳送這些資料的goroutine就會一直阻塞下去:

這是一種資源洩漏:goroutine會佔用記憶體和執行時資源。goroutine棧持有的堆引用會阻止GC回收資源。而且goroutine不能被垃圾回收,必須主動退出。

我們必須重新設計管道中的上游函式,在下游函式無法接收所有輸入資料時退出。一種方法就是讓輸出channel擁有一定的快取。快取可以儲存一定數量的資料。如果快取空間足夠,傳送操作就會馬上返回:

如果在channel建立時就知道需要傳送資料的數量,帶快取的channel會簡化程式碼。例如,我們可以重寫gen函式,拷貝一系列的整數到一個帶快取的channel而不是建立一個新的goroutine:

反過來我們看管道中被阻塞的goroutine,我們可以考慮為merge函式返回的輸出channel增加一個快取:

雖然這樣可以避免了程式中goroutine的阻塞,但這是很爛的程式碼。選擇快取大小為1取決於知道merge函式接收數字的數量和下游函式消費數字的數量。這是很不穩定的:如果我們向gen多傳送了一個資料,或者下游函式少消費了資料,我們就又一次阻塞了goroutine。

然而,我們需要提供一種方式,下游函式可以通知上游傳送者下游要停止接收資料。

顯式取消

當main函式決定在沒有從out接收所有的資料而要退出時,它需要通知上游的goroutine取消即將傳送的資料。可以通過向一個叫做done的channel傳送資料來實現。因為有兩個潛在阻塞的goroutine,main函式會傳送兩個資料:

對傳送goroutine而言,需要將傳送操作替換為一個select語句,要麼out發生傳送操作,要麼從done接收資料。done的資料型別是空的struct,因為其值無關緊要:僅僅表示out需要取消傳送操作。output 繼續在輸入channel迴圈執行,因此上游函式是不會阻塞的。(接下來我們會討論如何讓迴圈提早退出)

這種方法有一個問題:每一個下游函式需要知道潛在可能阻塞的上游傳送者的數量,以傳送響應的訊號讓其提早退出。跟蹤這些數量是無趣的而且很容易出錯。

我們需要一種能夠讓未知或無界數量的goroutine都能夠停止向下遊傳送資料的方法。在Go中,我們可以通過關閉一個channel實現。因為從一個關閉了的channel執行接收操作總能馬上成功,並返回相應資料型別的零值。

這意味著main函式僅通過關閉done就能實現將所有的傳送者解除阻塞。關閉操作是一個高效的對傳送者的廣播訊號。我們擴充套件管道中所有的函式接受done作為一個引數,並通過defer來實現相應channel的關閉操作。因此,無論main函式在哪一行退出都會通知上游退出。

現在每一個管道函式在done被關閉後就可以馬上返回了。merge函式中的output可以在接收管道的資料消費完之前返回,因為output函式知道上游傳送者sq會在done關閉後停止產生資料。同時,output通過defer語句保證wq.Done會在所有退出路徑上呼叫。

類似的,sq也可以在done關閉後馬上返回。sq通過defer語句使得任何退出路徑都能關閉其輸出channel out。

管道構建的指導思想如下:

  • 每一個階段在所有傳送操作完成後關閉輸出channel。
  • 每一個階段持續從輸入channel接收資料直到輸入channel被關閉或者生產者被解除阻塞(譯者:生產者退出)。

管道解除生產者阻塞有兩種方法:要麼保證有足夠的快取空間儲存將要被生產的資料,要麼顯式的通知生產者消費者要取消接收資料。

樹形摘要

讓我們來看一個更為實際的管道。

MD5是一個資訊摘要演算法,對於檔案校驗非常有用。命令列工具md5sum很有用,可以列印一系列檔案的摘要值。

我們的例子程式和md5sum類似,但是接受一個單一的資料夾作為引數,列印該資料夾下每一個普通檔案的摘要值,並按路徑名稱排序。

我們程式的main函式呼叫一個工具函式MD5ALL,該函式返回一個從路徑名稱到摘要值的雜湊表,然後排序並輸出結果:

MD5ALL是我們討論的核心。在 serial.go中,沒有采用任何併發,僅僅遍歷資料夾,讀取檔案並求出摘要值。

並行摘要求值

在parallel.go中,我們將MD5ALL分成兩階段的管道。第一個階段,sumFiles,遍歷資料夾,每個檔案一個goroutine進行求摘要值,然後將結果傳送一個資料型別為result的channel中:

sumFiles 返回兩個channel:一個用於生成結果,一個用於filepath.Walk返回錯誤。Walk函式為每一個普通檔案啟動一個goroutine,然後檢查done,如果done被關閉,walk馬上就會退出。

MD5All 從c中接收摘要值。MD5All 在遇到錯誤時提前退出,通過defer關閉done。

有界並行

parallel.go中實現的MD5ALL,對每一個檔案啟動了一個goroutine。在一個包含大量大檔案的資料夾中,這會導致超過機器可用記憶體的記憶體分配。(譯者注:即發生OOM)

我們可以通過限制讀取檔案的併發度來避免這種情況發生。在bounded.go中,我們通過建立一定數量的goroutine讀取檔案。現在我們的管道現在有三個階段:遍歷資料夾,讀取檔案並計算摘要值,收集摘要值。

第一個階段,walkFiles,輸出資料夾中普通檔案的檔案路徑:

中間的階段啟動一定數量的digester goroutine,從paths接收檔名稱,並向c傳送result結構:

和前一個例子不同,digester並不關閉其輸出channel,因為輸出channel是共享的,多個goroutine會向同一個channel傳送資料。MD5All 會在所有的digesters 結束後關閉響應的channel。

我們也可以讓每一個digester建立並返回自己的輸出channel,但如果這樣的話,我們需要額外的goroutine來扇入這些結果。
最後一個階段從c中接收所有的result資料,並從errc中檢查錯誤。這種檢查不能在之前的階段做,因為在這之前,walkFiles 可能被阻塞不能往下游傳送資料:

結論

這篇文章介紹瞭如果用Go構建流式資料管道的技術。在這樣的管道中處理錯誤有點取巧,因為管道中每一個階段可能被阻塞不能往下游傳送資料,下游階段可能已經不關心輸入資料。我們展示了關閉channel如何向所有管道啟動的goroutine廣播一個done訊號,並且定義了正確構建管道的指導思想。

深入閱讀:
• Go併發模式(視訊)展示了Go併發原語的基本概念和幾個實現的方法
• 高階Go併發模式(視訊)包含幾個更為複雜的Go併發原語的使用,尤其是select
• Douglas McIlroy的Squinting at Power Series論文展示了類似Go的併發模式如何為複雜的計算提供優雅的支援。

相關文章