要想跟蹤一個使用者的請求,必須有一個唯一的 ID 來標識這次請求呼叫了哪些函式、執行了哪些程式碼,然後通過這個唯一的 ID 把日誌資訊串聯起來。這樣就形成了一個日誌軌跡,也就實現了使用者的跟蹤,於是思路就有了。
- 在使用者請求的入口點生成 TraceID。
- 通過 context.WithValue 儲存 TraceID。
- 然後這個儲存著 TraceID 的 Context 就可以作為引數在各個協程或者函式間傳遞。
- 在需要記錄日誌的地方,通過 Context 的 Value 方法獲取儲存的 TraceID,然後把它和其他日誌資訊記錄下來。
- 這樣具備同樣 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 模式也稱為流水線模式,模擬的就是現實世界中的流水線生產。以手機組裝為例,整條生產流水線可能有成百上千道工序,每道工序只負責自己的事情,最終經過一道道工序組裝,就完成了一部手機的生產。
從技術上看,每一道工序的輸出,就是下一道工序的輸入,在工序之間傳遞的東西就是資料,這種模式稱為流水線模式,而傳遞的資料稱為資料流。
通過以上流水線模式示意圖,可以看到從最開始的生產,經過工序 1、2、3、4 到最終成品,這就是一條比較形象的流水線,也就是 Pipeline。
現在以組裝手機為例,講解流水線模式的使用。假設一條組裝手機的流水線有 3 道工序,分別是配件採購、配件組裝、打包成品,如圖所示:
從以上示意圖中可以看到,採購的配件通過 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))
從上述例子中,可以總結出一個流水線模式的構成:
- 流水線由一道道工序構成,每道工序通過 channel 把資料傳遞到下一個工序;
- 每道工序一般會對應一個函式,函式裡有協程和 channel,協程一般用於處理資料並把它放入 channel 中,整個函式會返回這個 channel 以供下一道工序使用;
- 最終要有一個組織者(示例中的 main 函式)把這些工序串起來,這樣就形成了一個完整的流水線,對於資料來說就是資料流。
扇出和扇入模式
手機流水線經過一段時間的運轉,組織者發現產能提不上去,經過調研分析,發現瓶頸在工序 2 配件組裝。工序 2 過慢,導致上游工序 1 配件採購速度不得不降下來,下游工序 3 沒太多事情做,不得不閒下來,這就是整條流水線產能低下的原因。
為了提升手機產能,組織者決定對工序 2 增加兩班人手。人手增加後,整條流水線的示意圖如下所示:
從改造後的流水線示意圖可以看到,工序 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 協議》,轉載必須註明作者和本文連結