Go高效併發 10 | Context:多執行緒併發控制神器

Swenson1992發表於2021-02-19

協程如何退出

一個協程啟動後,大部分情況需要等待裡面的程式碼執行完畢,然後協程會自行退出。但是如果有一種情景,需要讓協程提前退出怎麼辦呢?在下面的程式碼中,做了一個監控狗用來監控程式:

func main() {
   var wg sync.WaitGroup
   wg.Add(1)
   go func() {
      defer wg.Done()
      watchDog("【監控狗1】")
   }()
   wg.Wait()
}
func watchDog(name string){
   //開啟for select迴圈,一直後臺監控
   for{
      select {
      default:
         fmt.Println(name,"正在監控……")
      }
      time.Sleep(1*time.Second)
   }
}

通過 watchDog 函式實現了一個監控狗,它會一直在後臺執行,每隔一秒就會列印”監控狗正在監控……”的文字。

如果需要讓監控狗停止監控、退出程式,一個辦法是定義一個全域性變數,其他地方可以通過修改這個變數發出停止監控狗的通知。然後在協程中先檢查這個變數,如果發現被通知關閉就停止監控,退出當前協程。

但是這種方法需要通過加鎖來保證多協程下併發的安全,基於這個思路,有個升級版的方案:用 select+channel 做檢測,如下面的程式碼所示:

func main() {
   var wg sync.WaitGroup
   wg.Add(1)
   stopCh := make(chan bool) //用來停止監控狗
   go func() {
      defer wg.Done()
      watchDog(stopCh,"【監控狗1】")
   }()
   time.Sleep(5 * time.Second) //先讓監控狗監控5秒
   stopCh <- true //發停止指令
   wg.Wait()
}
func watchDog(stopCh chan bool,name string){
   //開啟for select迴圈,一直後臺監控
   for{
      select {
      case <-stopCh:
         fmt.Println(name,"停止指令已收到,馬上停止")
         return
      default:
         fmt.Println(name,"正在監控……")
      }
      time.Sleep(1*time.Second)
   }
}

這個示例是使用 select+channel 的方式改造的 watchDog 函式,實現了通過 channel 傳送指令讓監控狗停止,進而達到協程退出的目的。以上示例主要有兩處修改,具體如下:

  1. 為 watchDog 函式增加 stopCh 引數,用於接收停止指令;
  2. 在 main 函式中,宣告用於停止的 stopCh,傳遞給 watchDog 函式,然後通過 stopCh<-true 傳送停止指令讓協程退出。

初識 Context

以上示例是 select+channel 比較經典的使用場景,這裡也順便複習了 select 的知識。

通過 select+channel 讓協程退出的方式比較優雅,但是如果希望做到同時取消很多個協程呢?如果是定時取消協程又該怎麼辦?這時候 select+channel 的侷限性就凸現出來了,即使定義了多個 channel 解決問題,程式碼邏輯也會非常複雜、難以維護。

要解決這種複雜的協程問題,必須有一種可以跟蹤協程的方案,只有跟蹤到每個協程,才能更好地控制它們,這種方案就是 Go 語言標準庫提供的 Context。

現在通過 Context 重寫上面的示例,實現讓監控狗停止的功能,如下所示:

func main() {
   var wg sync.WaitGroup
   wg.Add(1)
   ctx,stop:=context.WithCancel(context.Background())
   go func() {
      defer wg.Done()
      watchDog(ctx,"【監控狗1】")
   }()
   time.Sleep(5 * time.Second) //先讓監控狗監控5秒
   stop() //發停止指令
   wg.Wait()
}
func watchDog(ctx context.Context,name string) {
   //開啟for select迴圈,一直後臺監控
   for {
      select {
      case <-ctx.Done():
         fmt.Println(name,"停止指令已收到,馬上停止")
         return
      default:
         fmt.Println(name,"正在監控……")
      }
      time.Sleep(1 * time.Second)
   }
}

相比 select+channel 的方案,Context 方案主要有 4 個改動點。

  1. watchDog 的 stopCh 引數換成了 ctx,型別為 context.Context。
  2. 原來的 case <-stopCh 改為 case <-ctx.Done(),用於判斷是否停止。
  3. 使用 context.WithCancel(context.Background()) 函式生成一個可以取消的 Context,用於傳送停止指令。這裡的 context.Background() 用於生成一個空 Context,一般作為整個 Context 樹的根節點。
  4. 原來的 stopCh <- true 停止指令,改為 context.WithCancel 函式返回的取消函式 stop()。

可以看到,這和修改前的整體程式碼結構一樣,只不過從 channel 換成了 Context。

什麼是 Context

一個任務會有很多個協程協作完成,一次 HTTP 請求也會觸發很多個協程的啟動,而這些協程有可能會啟動更多的子協程,並且無法預知有多少層協程、每一層有多少個協程。

如果因為某些原因導致任務終止了,HTTP 請求取消了,那麼它們啟動的協程怎麼辦?該如何取消呢?因為取消這些協程可以節約記憶體,提升效能,同時避免不可預料的 Bug。

Context 就是用來簡化解決這些問題的,並且是併發安全的。Context 是一個介面,它具備手動、定時、超時發出取消訊號、傳值等功能,主要用於控制多個協程之間的協作,尤其是取消操作。一旦取消指令下達,那麼被 Context 跟蹤的這些協程都會收到取消訊號,就可以做清理和退出操作。

Context 介面只有四個方法,下面進行詳細介紹,在開發中會經常使用它們,可以結合下面的程式碼來看。

type Context interface {
   Deadline() (deadline time.Time, ok bool)
   Done() <-chan struct{}
   Err() error
   Value(key interface{}) interface{}
}
  1. Deadline 方法可以獲取設定的截止時間,第一個返回值 deadline 是截止時間,到了這個時間點,Context 會自動發起取消請求,第二個返回值 ok 代表是否設定了截止時間。
  2. Done 方法返回一個只讀的 channel,型別為 struct{}。在協程中,如果該方法返回的 chan 可以讀取,則意味著 Context 已經發起了取消訊號。通過 Done 方法收到這個訊號後,就可以做清理操作,然後退出協程,釋放資源。
  3. Err 方法返回取消的錯誤原因,即因為什麼原因 Context 被取消。
  4. Value 方法獲取該 Context 上繫結的值,是一個鍵值對,所以要通過一個 key 才可以獲取對應的值。

Context 介面的四個方法中最常用的就是 Done 方法,它返回一個只讀的 channel,用於接收取消訊號。當 Context 取消的時候,會關閉這個只讀 channel,也就等於發出了取消訊號。

Context 樹

不需要自己實現 Context 介面,Go 語言提供了函式可以生成不同的 Context,通過這些函式可以生成一顆 Context 樹,這樣 Context 才可以關聯起來,父 Context 發出取消訊號的時候,子 Context 也會發出,這樣就可以控制不同層級的協程退出。

從使用功能上分,有四種實現好的 Context。

  • 空 Context:不可取消,沒有截止時間,主要用於 Context 樹的根節點。
  • 可取消的 Context:用於發出取消訊號,當取消的時候,它的子 Context 也會取消。
  • 可定時取消的 Context:多了一個定時的功能。
  • 值 Context:用於儲存一個 key-value 鍵值對。

從下圖 Context 的衍生樹可以看到,最頂部的是空 Context,它作為整棵 Context 樹的根節點,在 Go 語言中,可以通過 context.Background() 獲取一個根節點 Context。
Go高效併發 10 | Context:多執行緒併發控制神器
有了根節點 Context 後,這顆 Context 樹要怎麼生成呢?需要使用 Go 語言提供的四個函式。

  1. WithCancel(parent Context):生成一個可取消的 Context。
  2. WithDeadline(parent Context, d time.Time):生成一個可定時取消的 Context,引數 d 為定時取消的具體時間。
  3. WithTimeout(parent Context, timeout time.Duration):生成一個可超時取消的 Context,引數 timeout 用於設定多久後取消
  4. WithValue(parent Context, key, val interface{}):生成一個可攜帶 key-value 鍵值對的 Context。

以上四個生成 Context 的函式中,前三個都屬於可取消的 Context,它們是一類函式,最後一個是值 Context,用於儲存一個 key-value 鍵值對。

使用 Context 取消多個協程

取消多個協程也比較簡單,把 Context 作為引數傳遞給協程即可,還是以監控狗為例,如下所示:

wg.Add(3)
go func() {
   defer wg.Done()
   watchDog(ctx,"【監控狗2】")
}()
go func() {
   defer wg.Done()
   watchDog(ctx,"【監控狗3】")
}()

示例中增加了兩個監控狗,也就是增加了兩個協程,這樣一個 Context 就同時控制了三個協程,一旦 Context 發出取消訊號,這三個協程都會取消退出。

以上示例中的 Context 沒有子 Context,如果一個 Context 有子 Context,在該 Context 取消時會發生什麼呢?下面通過一幅圖說明:

Go高效併發 10 | Context:多執行緒併發控制神器
可以看到,當節點 Ctx2 取消時,它的子節點 Ctx4、Ctx5 都會被取消,如果還有子節點的子節點,也會被取消。也就是說根節點為 Ctx2 的所有節點都會被取消,其他節點如 Ctx1、Ctx3 和 Ctx6 則不會。

Context 傳值

Context 不僅可以取消,還可以傳值,通過這個能力,可以把 Context 儲存的值供其他協程使用。通過下面的程式碼來說明:

func main() {
   wg.Add(4) //記得這裡要改為4,原來是3,因為要多啟動一個協程

  //省略其他無關程式碼
   valCtx:=context.WithValue(ctx,"userId",2)
   go func() {
      defer wg.Done()
      getUser(valCtx)
   }()
   //省略其他無關程式碼
}
func getUser(ctx context.Context){
   for  {
      select {
      case <-ctx.Done():
         fmt.Println("【獲取使用者】","協程退出")
         return
      default:
         userId:=ctx.Value("userId")
         fmt.Println("【獲取使用者】","使用者ID為:",userId)
         time.Sleep(1 * time.Second)
      }
   }
}

這個示例是和上面的示例放在一起執行的,所以省略了上面例項的重複程式碼。其中,通過 context.WithValue 函式儲存一個 userId 為 2 的鍵值對,就可以在 getUser 函式中通過 ctx.Value(“userId”) 方法把對應的值取出來,達到傳值的目的。

Context 使用原則

Context 是一種非常好的工具,使用它可以很方便地控制取消多個協程。在 Go 語言標準庫中也使用了它們,比如 net/http 中使用 Context 取消網路的請求。

要更好地使用 Context,有一些使用原則需要儘可能地遵守。

  • Context 不要放在結構體中,要以引數的方式傳遞。
  • Context 作為函式的引數時,要放在第一位,也就是第一個引數。
  • 要使用 context.Background 函式生成根節點的 Context,也就是最頂層的 Context。
  • Context 傳值要傳遞必須的值,而且要儘可能地少,不要什麼都傳。
  • Context 多協程安全,可以在多個協程中放心使用。

以上原則是規範類的,Go 語言的編譯器並不會做這些檢查,要靠自己遵守。

總結

Context 通過 With 系列函式生成 Context 樹,把相關的 Context 關聯起來,這樣就可以統一進行控制。一聲令下,關聯的 Context 都會發出取消訊號,使用這些 Context 的協程就可以收到取消訊號,然後清理退出。在定義函式的時候,如果想讓外部給函式發取消訊號,就可以為這個函式增加一個 Context 引數,讓外部的呼叫者可以通過 Context 進行控制,比如下載一個檔案超時退出的需求。

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

相關文章