GO 語言的併發模式你瞭解多少?

阿兵雲原生發表於2023-10-14

工作中檢視專案程式碼,發現會存在使用 GO 語言做併發的時候出現各種各樣的異常情況,有的輸出結果和自己期望和設計的不一致,有的是程式直接阻塞住,更有甚者直接是程式 crash 掉。

實際上,出現上述的情況,還是因為我們對於 GO 語言的併發模型和涉及的 GO 語言基礎不夠紮實,誤解了語言的用法。

那麼,對於 GO 語言的併發模式,我們一起來梳理一波。 GO 語言常見的併發模式有這些:

  1. 建立模式
  2. 退出模式
  3. 管道模式
  4. 超時模式和取消模式

在 GO 語言裡面,我們們使用使用併發,自然離不開使用 GO 語言的協程 goroutine,通道 channel 和 多路複用 select,接下來就來看看各種模式都是如何去搭配使用這三個關鍵原語的

建立模式

使用過通道和協程的朋友對於建立模式肯定不會模式,這是一個非常常用的方式,也是一個非常簡單的使用方式:

  1. 主協程中呼叫 help 函式,返回一個通道 ch 變數
  2. 通道 ch 用於主協程和 子協程之間的通訊,其中通道的資料型別完全可以自行定義
type XXX struct{...}

func help(fn func()) chan XXX {
    ch := make(chan XXX)
    // 開啟一個協程
    go func(){
        // 此處的協程可以控制和外部的 主協程 透過 ch 來進行通訊,達到一定邏輯便可以執行自己的 fn 函式
        fn()
        ch <- XXX
    }()
}

func main(){
    ch := help(func(){
        fmt.Println("這是GO 語言 併發模式之 建立模式")
    })

    <- ch
}

退出模式

程式的退出我們應該也不會陌生,對於一些常駐的服務,如果是要退出程式,自然是不能直接就斷掉,此時會有一些連線和業務並沒有關閉,直接關閉程式會導致業務異常,例如在關閉過程中最後一個 http 請求沒有正常響應等等等

此時,就需要做優雅關閉了,對於協程 goroutine 退出有 3 種模式

  1. 分離模式
  2. join 模式
  3. notify-and-wait 模式

分離模式

此處的分離模式,分離這個術語實際上是執行緒中的術語,pthread detached

分離模式可以理解為,我們們建立的協程 goroutine,直接分離,建立子協程的父協程不用關心子協程是如何退出的,子協程的生命週期主要與它執行的主函式有關,我們們 return 之後,子協程也就結束了

對於這類分離模式的協程,我們們需要關注兩類,一種是一次性的任務,我們們 go 出來後,執行簡單任務完畢後直接退出,一種是常駐程式,需要優雅退出,處理一些垃圾回收的事情

例如這樣:

  1. 主程式中設定一個通道變數 ch ,型別為 os.Signal
  2. 然後主程式就開始各種建立協程執行自己的各種業務
  3. 直到程式收到了 syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT 任意一個訊號的時候,則會開始進行垃圾回收等清理工作,執行完畢後,程式再進行退出
func main(){
     ch := make(chan os.Signal)
     signal.Notify(c, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)

     // ...
     // go 程式執行其他業務
     // ...


    for i := range ch {
        switch i {
        case syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT:
            // 做一些清理工作
            os.Exit(0)
        }
    }
}

join 模式

看到這個關鍵字,是不是也似曾相識,和執行緒貌似很像,例如 執行緒中 父執行緒可以透過 pthread_join 來等待子執行緒結束,並且還可以獲取子執行緒的結束狀態

GO 語言中等待子協程退出並且獲取子協程的退出狀態,我們們就可以使用通道 channel 的方式來進行處理

例子1

等待一個子協程退出,並獲取退出狀態

  1. 主協程中呼叫 help 方法得到一個 ch 通道變數,主協程阻塞著讀 ch
  2. help 中開闢一個子協程去執行傳入的 fn 回撥函式,並傳參為 ok bool
  3. 實際 fn 函式判斷傳參 ok 是否是 true,若不是則返回具體的錯誤資訊,若是 true 則返回 nil
func help(f func(bool) error, ok bool) <-chan error {
    ch := make(chan error)
    go func() {
        ch <- f(ok)
    }()

    return ch
}

func fn(ok bool) error {
    if !ok {
        return errors.New("not ok ... ")
    }

    return nil
}

func main() {
    ch := help(fn, true)
    fmt.Println("help 111")
    err := <-ch
    fmt.Println("help 111 done ", err)

    ch = help(fn, false)
    fmt.Println("help 222")
    err = <-ch
    fmt.Println("help 222 done ", err)
}

看上如上程式,我們就可以知道,第一次呼叫 help(fn , true) ,主協程等待子協程退出的時候,會得到一個錯誤資訊,為 not ok ... , 第二次呼叫 help(fn , false) 的時候,返回的 err 是一個 nil

透過上述這種方式,主協程不僅可以輕易的等待一個子協程退出,還可以獲取到子協程退出的狀態

那麼,主協程如果是等待多個協程退出呢?需要如何處理?

例子2

主協程等待多個協程退出我們們就需要使用到 GO 中的 sync.WaitGroup

  1. 使用 help 函式,傳入回撥函式,引數1 bool,引數2 int ,其中引數 2 表示開闢子協程的個數,返回值為一個無緩衝的 channel 變數,資料型別是 struct{}
  2. 使用 var wg sync.WaitGroup ,開闢子協程的時候記錄一次 wg.Add(1),當子協程退出時 ,記錄退出 wg.Done()
  3. help 中再另起一個協程 wg.Wait() 等待所有子協程退出,並將 ch 變數寫入值
  4. 主協程阻塞讀取 ch 變數的值,待所有子協程都退出之後,help 中寫入到 ch 中的資料,主協程就能馬上收到 ch 中的資料,並退出程式
func help(f func(bool)error, ok bool, num int)chan struct{}{
    ch := make(chan struct{})

    var wg sync.WaitGroup
    for i:=0; i<num; i++ {
        wg.Add(1)

        go func(){
            f(ok)
            fmt.Println(" f done ")
            wg.Done()
        }() 
    }

    go func(){
        // 等待所有子協程退出
        wg.Wait()
        ch <- struct{}{}
    }()


    return ch
}

func fn(ok bool) error{

    time.Sleep(time.Second * 1)

    if !ok{
        return errors.New("not ok ... ")
    }

    return nil
}


func main(){
    ch := help(fn , true)
    fmt.Println("help 111")
     <- ch 
    fmt.Println("help 111 done ",err)

}

notify-and-wait 模式

可以看到上述模式,都是主協程等待一個子協程,或者多個子協程結束後,主協程再進行退出,或者處理完垃圾回收後退出

那麼如果主協程要主動通知子協程退出,我們應該要如何處理呢?

同樣的問題,如果主協程自己退出了,而沒有通知其他子協程退出,這是會導致業務資料異常或者丟失的,那麼此刻我們就可以使用到 notify-and-wait 模式 來進行處理

我們就直接來寫一個主協程通知並等待多個子協程退出的 demo:

  1. 主協程呼叫 help 函式,得到一個 quit chan struct{} 型別的通道變數,主協程阻塞讀取 quit 的值
  2. help 函式根據傳入的引數 num 來建立 num 個子協程,並且使用 sync.WaitGroup 來控制
  3. 當主協程在 quit 通道中寫入資料時,主動通知所有子協程退出
  4. help 中的另外一個協程讀取到 quit 通道中的資料,便 close 掉 j 通道,觸發所有的子協程讀取 j 通道值的時候,得到的 ok 為 false,進而所有子協程退出
  5. wg.Wait() 等待所有子協程退出後,再在 quit 中寫入資料
  6. 主協程此時從 quit 中讀取到資料,則知道所有子協程全部退出,自己的主協程即刻退出
func fn(){
   // 模擬在處理業務
   time.Sleep(time.Second * 1)
}

func help(num int, f func()) chan struct{}{
   quit := make(chan struct{})
   j := make(chan int)

   var wg sync.WaitGroup

   // 建立子協程處理業務
   for i:=0;i<num;i++{
      wg.Add(1)
      go func(){
         defer wg.Done()

         _,ok:=<-j
         if !ok{
            fmt.Println("exit child goroutine .")
            return
         }
         // 子協程 正常執行業務
         f()
      }()
   }


   go func(){
      <-quit
      close(j)
      // 等待子協程全部退出
      wg.Wait()

      quit <- struct{}{}

   }()

   return quit
}


func main(){
   quit := help(10, fn)
   // 模擬主程式處理在處理其他事項
   // ...
   time.Sleep(time.Second * 10)

   quit <- struct{}{}

   // 此處等待所有子程式退出
   select{
   case <- quit:
      fmt.Println(" programs exit. ")
   }


}

上述程式執行結果如下,可以看到 help 函式建立了 10 個子協程,主協程主動通知子協程全部退出,退出的時候也是 10 個子協程退出了,主協程才退出

上述程式,如果某一個子協程出現了問題,導致子協程不能完全退出,也就是說某些子協程在 f 函式中阻塞住了,那麼這個時候主協程豈不是一直無法退出???

那麼此時,在主協程通知子協程退出的時候,我們加上一個超時時間,表達意思為,超過某個時間,如果子協程還沒有全部退出完畢,那麼主協程仍然主動關閉程式,可以這樣寫:

  1. 設定一個定時器, 3 秒後會觸發,即可以從 t.C 中讀取到資料
t := time.NewTimer(time.Second * 3)
defer t.Stop()

// 此處等待所有子程式退出
select{
case <-t.C:
   fmt.Println("timeout programs exit. ")
case <- quit:
   fmt.Println(" 111 programs exit. ")
}

管道模式

說到管理,或許大家對 linux 裡面的管道更加熟悉吧,例如使用 linux 命令找到檔案中的 golang 這個字串

cat xxx.txt |grep "golang"

那麼對於 GO 語言併發模式中的管道模式也是類似的效果,我們就可以用這個管道模式來過濾資料

例如我們可以設計這樣一個程式,兄弟們可以動起手來寫一寫,評論區見哦:

  1. 整個程式總共使用 2 個通道
  2. help 函式中傳輸資料量 50 ,邏輯計算能夠被 5 整除的資料寫到第一個通道 ch1 中
  3. 另一個協程阻塞讀取 ch1 中的內容,並將取出的資料乘以 3 ,將結果寫入到 ch2 中
  4. 主協程就阻塞讀取 ch2 的內容,讀取到內容後,挨個列印出來

管道模式有兩種模式,扇出模式 和 扇入模式,這個比較好理解

  1. 扇出模式:多種型別的資料從同一個通道 channel 中讀取資料,直到通道關閉
  2. 扇入模式:輸入的時候有多個通道channel,程式將所有的通道內資料匯聚,統一輸入到另外一個通道channel A 裡面,另外一個程式則從這個通道channel A 中讀取資料,直到這個通道A關閉為止

超時模式和取消模式化

超時模式

上述例子中有專門說到如何去使用他,實際上我們還可以這樣用:

select{
case <- time.Afer(time.Second * 2):
   fmt.Println("timeout programs exit. ")
case <- quit:
   fmt.Println(" 111 programs exit. ")
}

取消模式

則是使用了 GO 語言的 context 包中的提供了上下文機制,可以在協程 goroutine 之間傳遞 deadline,取消等訊號

我們使用的時候例如可以這樣:

  1. 使用 context.WithCancel 建立一個可以被取消的上下文,啟動一個協程 在 3 秒後關閉上下文
  2. 使用 for 迴圈模擬處理業務,預設會走 select 的 default 分支
  3. 3 秒後 走到 select 的 ctx.Done(),則進入到了取消模式,程式退出
ctx, cancelFunc := context.WithCancel(context.Background())

go func() {
   time.Sleep(time.Second * 3)
   cancelFunc()
}()

for {
   select {
   case <-ctx.Done():
      fmt.Println("program exit .")
      return
   default:
      fmt.Println("I'm still here.")
      time.Sleep(time.Second)
   }
}

總的來說,今天分享了 GO 語言中常見的幾種併發模式:建立模式,退出模式,管道模式,超時模式和取消模式,更多的,還是要我們要思考其原理和應用起來,學習他們才能更加的有效

歡迎點贊,關注,收藏

朋友們,你的支援和鼓勵,是我堅持分享,提高質量的動力

好了,本次就到這裡

技術是開放的,我們的心態,更應是開放的。擁抱變化,向陽而生,努力向前行。

我是阿兵雲原生,歡迎點贊關注收藏,下次見~

本作品採用《CC 協議》,轉載必須註明作者和本文連結
關注微信公眾號:阿兵雲原生

相關文章