Golang協程併發的流水線模型

屈天航發表於2020-11-18

背景

最近由於效能問題,後端服務一直在做python到golang的遷移和重構。go語言精簡優雅,既有編譯型語言的嚴謹和高效能,又有解釋型語言的開發效率,出色的併發效能也是go區別於其他語言的一大特色。go的併發程式設計程式碼雖然簡單,但重在其併發模型和流程的設計。所以這裡總結下golang協程併發常用的流水線模型。

簡單的流水線思維

流水線模式並不是什麼新奇的概念,但是它能極大地提高生產效率。比如實際生活中的汽車生產流水線,流水線上的每一個流程負責不同的工作,比如第一個流程是拼裝車身,第二個流程是安裝發動機,第三個流程是裝輪胎...,這些步驟我們可以類比成go併發流程中的協程,每一個協程就是一個任務。流水線上面傳遞的車身、發動機、輪胎,這些我們可以類比成協程間需要傳遞的資料,而在這些流程(協程)間傳遞這些配件(資料),自然就要通過傳送帶(channel)。在流水線上,我們裝四個輪胎肯定不是一個一個來裝的,肯定是有四個機械臂同時來裝。因此裝輪胎這個步驟我們有4個協程在併發工作來提高效率。這麼一來,流水線模型的基本要素就構成了。
Golang的併發模型靈感其實都來自我們生活,對程式而言,高的生產效率就是高的效能。在Golang中,流水線由多個流程節點組成,流程之間通過channel連線,每個流程節點可以由多個同時執行的goroutine組成。
image.png

如何構造流水線

有了流水線模式的思維,接下來就是如何構造流水線了。簡單來說,其實就是通過channel將任務流程連線起來,兩個相鄰的流程互為生產者和消費者,通過channel進行通訊。耗時的流程可以將任務分散到多個協程來執行。
我們先來看一個最簡單的流水線,如下圖,A是生產者流程,B是它的消費流程,同時又是C的生產者流程。A,B,C三個協程直接,通過讀寫channel進行通訊。
image.png

那如果此時B流程可以將a channel中的任務併發執行呢,很簡單,我們只需要起多個B協程就可以了。如下圖。
image.png

總之,我們構造流水線併發的思路是關注資料的流動,資料流動的過程交給channel,channel兩端資料處理的每個環節都交給goroutine,這個流程連起來,就構成了流水線模型。

關於channel

為什麼我們可以選擇channel來進行協程間的通訊呢,協程之間又是怎麼保持同步順序呢,當然這都要歸功於channel。channel是go提供的程式內協程間的通訊方式,它是協程/執行緒安全的,channe的讀寫阻塞會導致協程的切換。
channel的操作和狀態組合可以有以下幾種情況:
image.png

**有1個特殊場景**:當`nil`的通道在`select`的某個`case`中時,這個case會阻塞,但不會造成死鎖。

channel不僅可以保證協程安全的資料流動,還可以保證協程的同步。當有併發問題時,channel也是我們首先應該想到的資料結構。不過顯而易見,當使用有緩衝區的channel時,才能達到協程併發的效果,並且生產者和消費者的協程間是相對同步的。使用無緩衝區的channel時,是沒有併發效果的,協程間是絕對同步的,生產者和消費者必須同時寫和讀協程才能執行。
channel關注的是資料的流動,這種場景下都可以考慮使用channel。比如:訊息傳遞、訊號廣播、任務分發、結果彙總、同步與非同步、併發控制... 更多的不在這裡贅述了,總之,Share memory by communicating, don't communicate by sharing memory.

流水線模型例項

舉個簡單栗子,計算80000以內的質數並輸出。
這個例子如果我們採用非併發的方式,就是for迴圈80000,挨個判斷是不是素數再輸出。不過如果我們採用流水線的併發模型會更高效。

從資料流動的角度來分析,需要遍歷生成1-80000的數字到一個channel中,數字判斷是否為素數,輸出結果到一個channel中。因此我們需要兩個channel,channel的兩端就設計成協程即可。
1、遍歷生成原始80000個資料(生產者)
2、計算這80000個資料中的素數(生產者+消費者)
3、取結果輸出(消費者)

程式碼如下:

package gen_channel
import "fmt"
import "time"
func generate_source(data_source_chan chan int) {
   for i := 1; i <= 80000; i++ {
      data_source_chan <- i
   }
   fmt.Println("寫入協程結束")
   close(data_source_chan)
}
func generate_sushu(data_source_chan chan int, data_result_chan chan int, gen_chan chan bool) {
   for num:= range data_source_chan {
      falg := true
 for i := 2; i < num; i++ {
         if num%i == 0 {
            falg = false
 break }
      }
      if falg == true {
         data_result_chan <- num
      }
   }
   fmt.Println("該協程結束")
   gen_chan <- true
}
func workpool(data_source_chan chan int, data_result_chan chan int, gen_chan chan bool, gen_num int){
   // 開啟8個協程
 for i := 0; i < gen_num; i++ {
      go generate_sushu(data_source_chan, data_result_chan, gen_chan)
   }
}
func Channel_main() {
   // 任務資料
   data_source_chan := make(chan int, 2000)
   // 結果資料
   data_result_chan := make(chan int, 2000)
   // 所有任務協程是否結束
   gen_chan := make(chan bool, 8)
   time1 := time.Now().Unix()
   go generate_source(data_source_chan)
   // 協程池,任務分發
   workpool(data_source_chan, data_result_chan, gen_chan, 8)
   // 所有協程結束後關閉結果資料channel
   go func() {
      for i := 0; i < 8; i++ {
         <-gen_chan
      }
      close(data_result_chan)
      fmt.Println("spend timeis ", time.Now().Unix()-time1)
   }()
   for date_result := range data_result_chan {
      fmt.Println(date_result)
   }
}

上面這段程式碼中。data_source_chandata_result_chan這兩個channel分別用來放原始資料和結果資料,buffer分別為2000。

generate_source協程: 生產資料,它會把資料寫入data_source_chan通道,全部寫入完成後關閉通道。
generate_sushu協程: 負責計算並判斷data_source_chan中的資料是否為質數,是的話就寫入data_result_chan通道。
主協程for date_result := range data_result_chan: 最後負責讀取data_result_chan中的結果,直到data_result_chan關閉後結束程式。

可以看到我們通過workpool方法起了8個generate_sushu協程來併發處理data_source_chan的任務。那麼就有一個問題,如何知道所有資料都已處理完畢呢,等到生產者generate_source協程結束data_source_chan關閉嗎? 恐怕不是,因為可能data_source_chan關閉後8個任務協程仍然在繼續計算。那麼只能等8個協程全部處理完畢後,才能說明所有資料已處理完,從而才能關閉data_result_chan,然後主協程讀取data_result_chan結束。

因此我們這裡引入了另一個channel:gen_chan,來記錄計算結束的任務。每個generate_sushu協程處理完,就寫入一個記錄到channel中。因此我們有一個匿名協程,當可以從gen_chan中取8個結果出來的話,就說明所有協程已計算完成,那麼可以關上阻塞程式的最後閥門data_result_chan

當然這種設計方式並不唯一,我們也可以不用統一的data_result_chan來接收結果,而是每個協程分配一個channel來存放結果,最後再merge到一起。

可能大家覺得這種方式很複雜,確實比較高效但寫起來並不友好,那有沒有更友好的方式呢?

sync包

在處理併發任務時我們首先想到的應該是channel,但有時候channel不是萬能或者最方便的,所以go也為我們提供了sync包。

sync包提供了各種非同步及鎖型別及其內建方法。用起來也很方便,比如Mutex就是給協程加鎖,某個時段內不能有多個協程訪問同一段程式碼。WaitGroup就是等待一些工作完成後,再進行下一步工作。Once可以用來確保協程中某個函式只執行1次...當我們面對一個併發問題的時候,應該去分析採用哪種協程同步方式,是channel還是Mutex呢。這需要看我們關注的是資料的流動還是資料的安全性。篇幅原因這裡不再展開講了。

  1. Mutex:互斥鎖
  2. RWMutex:讀寫鎖
  3. WaitGroup:等待組
  4. Once:單次執行
  5. Cond:訊號量
  6. Pool:臨時物件池
  7. Map:自帶鎖的map

我們接著上面質數的問題,使用sync中的WaitGroup,會讓我們的程式碼更加友好,因為我們不需要引入一個channel來記錄是否4個車輪都換完了,讓WaitGroup來做就好了。

 package gen_channel
import (
   "fmt"
 "time")
import "sync"
func generate_source3(data_source_chan chan int) {
   for i := 1; i <= 80000; i++ {
      data_source_chan <- i
   }
   fmt.Println("寫入協程結束")
   close(data_source_chan)
}
func generate_sushu3(data_source_chan, data_result_chan chan int, wg *sync.WaitGroup) {
   defer wg.Done()
   for num := range data_source_chan {
      falg := true
 for i := 2; i < num; i++ {
         if num%i == 0 {
            falg = false
 break }
      }
      if falg == true {
         data_result_chan <- num
      }
   }
   fmt.Println("該協程結束")
}
func workpool3(data_source_chan chan int, data_result_chan chan int, wg *sync.WaitGroup, gen_num int) {
   // 開啟8個協程
 for i := 0; i < gen_num; i++ {
      wg.Add(1)
      go generate_sushu3(data_source_chan, data_result_chan, wg)
   }
}
func Channel_main3() {
   data_source_chan := make(chan int, 500)
   data_result_chan := make(chan int, 2000)
   time1 := time.Now().Unix()
   var wg sync.WaitGroup
 go generate_source3(data_source_chan)
   // 開啟8個協程
 for i := 0; i < 8; i++ {
      wg.Add(1)
      go generate_sushu3(data_source_chan, data_result_chan, &wg)
   }
   wg.Wait()
   close(data_result_chan)
   fmt.Println("spend timeis ", time.Now().Unix()-time1)
   for date_result := range data_result_chan {
      fmt.Println(date_result)
   }
}

總結

流水線模式的設計要關注資料的流動,然後在資料流動的路徑中將資料放到channel中,將channel的兩端設計成協程。
併發設計中channel和sync可以從開發效率和效能的角度自由組合,channel不一定是最優解
寫入channel的協程來控制該協程的關閉,消費者協程不關閉讀協程,防止報錯。養成在協程入口限制channel讀寫型別的習慣。

以上是我們在go併發的流水線模型中的一些總結。可以看出go的協程併發更考驗我們的設計能力,因為協程間的同步和資料傳遞都交給了開發者來設計。同時也留給我們一些引申思考,協程在IO密集和CPU密集的情況下是否都能大幅提高效能呢?是否和channel的緩衝區或者併發設計有關呢?協程異常該怎麼處理呢?go的協程和python的協程又有什麼區別呢?...我們後面慢慢探討~

相關文章