Go高效併發 11 | 併發模式:Go 語言中即學即用的高效併發模式

Swenson1992發表於2021-02-19

要想跟蹤一個使用者的請求,必須有一個唯一的 ID 來標識這次請求呼叫了哪些函式、執行了哪些程式碼,然後通過這個唯一的 ID 把日誌資訊串聯起來。這樣就形成了一個日誌軌跡,也就實現了使用者的跟蹤,於是思路就有了。

  1. 在使用者請求的入口點生成 TraceID。
  2. 通過 context.WithValue 儲存 TraceID。
  3. 然後這個儲存著 TraceID 的 Context 就可以作為引數在各個協程或者函式間傳遞。
  4. 在需要記錄日誌的地方,通過 Context 的 Value 方法獲取儲存的 TraceID,然後把它和其他日誌資訊記錄下來。
  5. 這樣具備同樣 TraceID 的日誌就可以被串聯起來,達到日誌跟蹤的目的。

以上思路實現的核心是 Context 的傳值功能。

for select 迴圈模式

for select 迴圈模式非常常見,它一般和 channel 組合完成任務,程式碼格式如下:

for { //for無限迴圈,或者for range迴圈
  select {
    //通過一個channel控制
  }
}

這是一種 for 迴圈 +select 多路複用的併發模式,哪個 case 滿足就執行哪個,直到滿足一定的條件退出 for 迴圈(比如傳送退出訊號)。

從具體實現上講,for select 迴圈有兩種模式,一種是監控狗例子中的無限迴圈模式,只有收到終止指令才會退出,如下所示:

for  {
   select {
   case <-done:
      return
   default:
      //執行具體的任務
   }
}

這種模式會一直執行 default 語句中的任務,直到 done 這個 channel 被關閉為止。

第二種模式是 for range select 有限迴圈,一般用於把可以迭代的內容傳送到 channel 上,如下所示:

for _,s:=range []int{}{
   select {
   case <-done:
      return
   case resultCh <- s:
   }
}

這種模式也會有一個 done channel,用於退出當前的 for 迴圈,而另外一個 resultCh channel 用於接收 for range 迴圈的值,這些值通過 resultCh 可以傳送給其他的呼叫者。

select timeout 模式

假如需要訪問伺服器獲取資料,因為網路的不同響應時間不一樣,為保證程式的質量,不可能一直等待網路返回,所以需要設定一個超時時間,這時候就可以使用 select timeout 模式,如下所示:

func main() {
   result := make(chan string)
   go func() {
      //模擬網路訪問
      time.Sleep(8 * time.Second)
      result <- "服務端結果"
   }()
   select {
   case v := <-result:
      fmt.Println(v)
   case <-time.After(5 * time.Second):
      fmt.Println("網路訪問超時了")
   }
}

select timeout 模式的核心在於通過 time.After 函式設定一個超時時間,防止因為異常造成 select 語句的無限等待。

小提示:如果可以使用 Context 的 WithTimeout 函式超時取消,要優先使用。

Pipeline 模式

Pipeline 模式也稱為流水線模式,模擬的就是現實世界中的流水線生產。以手機組裝為例,整條生產流水線可能有成百上千道工序,每道工序只負責自己的事情,最終經過一道道工序組裝,就完成了一部手機的生產。

從技術上看,每一道工序的輸出,就是下一道工序的輸入,在工序之間傳遞的東西就是資料,這種模式稱為流水線模式,而傳遞的資料稱為資料流。

Go

通過以上流水線模式示意圖,可以看到從最開始的生產,經過工序 1、2、3、4 到最終成品,這就是一條比較形象的流水線,也就是 Pipeline。

現在以組裝手機為例,講解流水線模式的使用。假設一條組裝手機的流水線有 3 道工序,分別是配件採購、配件組裝、打包成品,如圖所示:

Go高效併發 11 | 併發模式:Go 語言中即學即用的高效併發模式

從以上示意圖中可以看到,採購的配件通過 channel 傳遞給工序 2 進行組裝,然後再通過 channel 傳遞給工序 3 打包成品。相對工序 2 來說,工序 1 是生產者,工序 3 是消費者。相對工序 1 來說,工序 2 是消費者。相對工序 3 來說,工序 2 是生產者。

//工序1採購
func buy(n int) <-chan string {
   out := make(chan string)
   go func() {
      defer close(out)
      for i := 1; i <= n; i++ {
         out <- fmt.Sprint("配件", i)
      }
   }()
   return out
}

首先我們定義一個採購函式 buy,它有一個引數 n,可以設定要採購多少套配件。採購程式碼的實現邏輯是通過 for 迴圈產生配件,然後放到 channel 型別的變數 out 裡,最後返回這個 out,呼叫者就可以從 out 中獲得配件。

有了採購好的配件,就可以開始組裝了,如下面的程式碼所示:

//工序2組裝
func build(in <-chan string) <-chan string {
   out := make(chan string)
   go func() {
      defer close(out)
      for c := range in {
         out <- "組裝(" + c + ")"
      }
   }()
   return out
}

組裝函式 build 有一個 channel 型別的引數 in,用於接收配件進行組裝,組裝後的手機放到 channel 型別的變數 out 中返回。

有了組裝好的手機,就可以放在精美的包裝盒中售賣了,而包裝的操作是工序 3 完成的,對應的函式是 pack,如下所示:

//工序3打包
func pack(in <-chan string) <-chan string {
   out := make(chan string)
   go func() {
      defer close(out)
      for c := range in {
         out <- "打包(" + c + ")"
      }
   }()
   return out
}

函式 pack 的程式碼實現和組裝函式 build 基本相同,這裡不再贅述。

流水線上的三道工序都完成後,就可以通過一個組織者把三道工序組織在一起,形成一條完整的手機組裝流水線,這個組織者可以是常用的 main 函式,如下面的程式碼所示:

func main() {
   coms := buy(10)    //採購10套配件
   phones := build(coms) //組裝10部手機
   packs := pack(phones) //打包它們以便售賣
   //輸出測試,看看效果
   for p := range packs {
      fmt.Println(p)
   }
}

按照流水線工序進行呼叫,最終把手機打包以便售賣,過程如下所示:

打包(組裝(配件1))
打包(組裝(配件2))
打包(組裝(配件3))
打包(組裝(配件4))
打包(組裝(配件5))
打包(組裝(配件6))
打包(組裝(配件7))
打包(組裝(配件8))
打包(組裝(配件9))
打包(組裝(配件10))

從上述例子中,可以總結出一個流水線模式的構成:

  1. 流水線由一道道工序構成,每道工序通過 channel 把資料傳遞到下一個工序;
  2. 每道工序一般會對應一個函式,函式裡有協程和 channel,協程一般用於處理資料並把它放入 channel 中,整個函式會返回這個 channel 以供下一道工序使用;
  3. 最終要有一個組織者(示例中的 main 函式)把這些工序串起來,這樣就形成了一個完整的流水線,對於資料來說就是資料流。

扇出和扇入模式

手機流水線經過一段時間的運轉,組織者發現產能提不上去,經過調研分析,發現瓶頸在工序 2 配件組裝。工序 2 過慢,導致上游工序 1 配件採購速度不得不降下來,下游工序 3 沒太多事情做,不得不閒下來,這就是整條流水線產能低下的原因。

為了提升手機產能,組織者決定對工序 2 增加兩班人手。人手增加後,整條流水線的示意圖如下所示:

Go高效併發 11 | 併發模式:Go 語言中即學即用的高效併發模式

從改造後的流水線示意圖可以看到,工序 2 共有工序 2-1、工序 2-2、工序 2-3 三班人手,工序 1 採購的配件會被工序 2 的三班人手同時組裝,這三班人手組裝好的手機會同時傳給merge 元件匯聚,然後再傳給工序 3 打包成品。在這個流程中,會產生兩種模式:扇出和扇入。

示意圖中紅色的部分是扇出,對於工序 1 來說,它同時為工序 2 的三班人手傳遞資料(採購配件)。以工序 1 為中點,三條傳遞資料的線發散出去,就像一把開啟的扇子一樣,所以叫扇出。

示意圖中藍色的部分是扇入,對於 merge 元件來說,它同時接收工序 2 三班人手傳遞的資料(組裝的手機)進行匯聚,然後傳給工序 3。以 merge 元件為中點,三條傳遞資料的線匯聚到 merge 元件,也像一把開啟的扇子一樣,所以叫扇入。

小提示:扇出和扇入都像一把開啟的扇子,因為資料傳遞的方向不同,所以叫法也不一樣,扇出的資料流向是發散傳遞出去,是輸出流;扇入的資料流向是匯聚進來,是輸入流。

已經理解了扇出扇入的原理,就可以開始改造流水線了。這次改造中,三道工序的實現函式 buy、build、pack 都保持不變,只需要增加一個 merge 函式即可,如下面的程式碼所示:

//扇入函式(元件),把多個chanel中的資料傳送到一個channel中
func merge(ins ...<-chan string) <-chan string {
   var wg sync.WaitGroup
   out := make(chan string)
   //把一個channel中的資料傳送到out中
   p:=func(in <-chan string) {
      defer wg.Done()
      for c := range in {
         out <- c
      }
   }
   wg.Add(len(ins))
   //扇入,需要啟動多個goroutine用於處於多個channel中的資料
   for _,cs:=range ins{
      go p(cs)
   }
   //等待所有輸入的資料ins處理完,再關閉輸出out
   go func() {
      wg.Wait()
      close(out)
   }()
   return out
}

新增的 merge 函式的核心邏輯就是對輸入的每個 channel 使用單獨的協程處理,並將每個協程處理的結果都傳送到變數 out 中,達到扇入的目的。總結起來就是通過多個協程併發,把多個 channel 合成一個。

在整條手機組裝流水線中,merge 函式非常小,而且和業務無關,不能當作一道工序,所以我把它叫作元件。該 merge 元件是可以複用的,流水線中的任何工序需要扇入的時候,都可以使用 merge 元件。

小提示:這次的改造新增了 merge 函式,其他函式保持不變,符合開閉原則。開閉原則規定“軟體中的物件(類,模組,函式等等)應該對於擴充套件是開放的,但是對於修改是封閉的”。

有了可以複用的 merge 元件,現在來看流水線的組織者 main 函式是如何使用扇出和扇入併發模式的,如下所示:

func main() {
   coms := buy(100)    //採購100套配件
   //三班人同時組裝100部手機
   phones1 := build(coms)
   phones2 := build(coms)
   phones3 := build(coms)
   //匯聚三個channel成一個
   phones := merge(phones1,phones2,phones3)
   packs := pack(phones) //打包它們以便售賣
   //輸出測試,看看效果
   for p := range packs {
      fmt.Println(p)
   }
}

這個示例採購了 100 套配件,也就是開始增加產能了。於是同時呼叫三次 build 函式,也就是為工序 2 增加人手,這裡是三班人手同時組裝配件,然後通過 merge 函式這個可複用的元件將三個 channel 匯聚為一個,然後傳給 pack 函式打包。

這樣通過扇出和扇入模式,整條流水線就被擴充好了,大大提升了生產效率。因為已經有了通用的扇入元件 merge,所以整條流水中任何需要扇出、扇入提高效能的工序,都可以複用 merge 元件做扇入,並且不用做任何修改。

Futures 模式

Pipeline 流水線模式中的工序是相互依賴的,上一道工序做完,下一道工序才能開始。但是在實際需求中,也有大量的任務之間相互獨立、沒有依賴,所以為了提高效能,這些獨立的任務就可以併發執行。

舉個例子,比如打算做頓火鍋吃,那麼就需要洗菜、燒水。洗菜、燒水這兩個步驟相互之間沒有依賴關係,是獨立的,那麼就可以同時做,但是最後做火鍋這個步驟就需要洗好菜、燒好水之後才能進行。這個做火鍋的場景就適用 Futures 模式。

Futures 模式可以理解為未來模式,主協程不用等待子協程返回的結果,可以先去做其他事情,等未來需要子協程結果的時候再來取,如果子協程還沒有返回結果,就一直等待。下面的程式碼進行演示:

//洗菜
func washVegetables() <-chan string {
   vegetables := make(chan string)
   go func() {
      time.Sleep(5 * time.Second)
      vegetables <- "洗好的菜"
   }()
   return vegetables
}
//燒水
func boilWater() <-chan string {
   water := make(chan string)
   go func() {
      time.Sleep(5 * time.Second)
      water <- "燒開的水"
   }()
   return water
}

洗菜和燒水這兩個相互獨立的任務可以一起做,所以示例中通過開啟協程的方式,實現同時做的功能。當任務完成後,結果會通過 channel 返回。

小提示:示例中的等待 5 秒用來描述洗菜和燒火的耗時。

在啟動兩個子協程同時去洗菜和燒水的時候,主協程就可以去幹點其他事情(示例中是眯一會),等睡醒了,要做火鍋的時候,就需要洗好的菜和燒好的水這兩個結果了。用下面的程式碼進行演示:

func main() {
   vegetablesCh := washVegetables() //洗菜
   waterCh := boilWater()           //燒水
   fmt.Println("已經安排洗菜和燒水了,我先眯一會")
   time.Sleep(2 * time.Second)

   fmt.Println("要做火鍋了,看看菜和水好了嗎")
   vegetables := <-vegetablesCh
   water := <-waterCh
   fmt.Println("準備好了,可以做火鍋了:",vegetables,water)
}

Futures 模式下的協程和普通協程最大的區別是可以返回結果,而這個結果會在未來的某個時間點使用。所以在未來獲取這個結果的操作必須是一個阻塞的操作,要一直等到獲取結果為止。

如果大任務可以拆解為一個個獨立併發執行的小任務,並且可以通過這些小任務的結果得出最終大任務的結果,就可以使用 Futures 模式。

總結

併發模式和設計模式很相似,都是對現實場景的抽象封裝,以便提供一個統一的解決方案。但和設計模式不同的是,併發模式更專注於非同步和併發。

本作品採用《CC 協議》,轉載必須註明作者和本文連結
?

相關文章