如何正確使用 go 中的 Context

大家好,我是漁夫子。今天跟大家聊聊 context 的設計機制及如何正確使用。

01 爲什麼要引入 Context

context.Context 是 Go 中定義的一個接口類型,從 1.7 版本中開始引入。其主要作用是在一次請求經過的所有協程或函數間傳遞取消信號及共享數據,以達到父協程對子協程的管理和控制的目的。

需要注意的是 context.Context 的作用範圍是一次請求的生命週期,即隨着請求的產生而產生,隨着本次請求的結束而結束。如圖所示:

02 什麼是 context.Context

在 context 包中,我們看到 context.Context 的定義實際上是一個接口類型,該接口定義了獲取上下文的 Deadline 的函數,根據 key 獲取 value 值的函數、還有獲取 done 通道的函數。如下:

type Context interface {
  Deadline() (deadline time.Time, ok bool)
  Done() <-chan struct{}
  Err() error
  Value(key interface{}) interface{}
}

由定義的接口函數可知,對於傳遞取消信號的行爲我們可以描述爲:當協程運行時間達到 Deadline 時,就會調用取消函數,關閉 done 通道,往 done 通道中輸入一個空結構體消息 struct{}{},這時所有監聽 done 通道的子協程都會收到該消息,便知道父協程已經關閉,需要自己也結束運行

下面是一個使用 Context 的簡易示例,我們通過該示例來說明父子協程之間是如何傳遞取消信號的。

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
    defer cancel()
    go doSomethingCool(ctx)
    select {
    case <-ctx.Done():
        fmt.Println("oh no, I've exceeded the deadline")
    }
}
func doSomethingCool(ctx context.Context) {
    for {
    select {
    case <-ctx.Done():
      fmt.Println("timed out")
      return
    default:
      fmt.Println("doing something cool")
    }
    time.Sleep(500 * time.Millisecond)
    }
}

由示例可知,main 協程和 doSomething 函數之間的唯一關聯就是 ctx.Done()。當子協程從 ctx.Done() 通道中接收到輸出時(因爲超時自動取消或主動調用了 cancel 函數),即認爲是父協程不再需要子協程返回的結果了,子協程就會直接返回,不再執行其他的邏輯。

03 Context 的作用一:協程間傳遞信號

3.1 如何創建帶可以傳遞信號的 Context

在開頭處我們得知 Context 本質是一個接口類型。接口類型是需要具體的結構體起來實現的。那我們需要自定義結構體類型來實現這些接口嗎?答案是不需要。因爲在 context 包中已經定義好了所需場景的結構體,這些結構體已經幫我們實現了 Context 接口的方法,在項目中就已經夠用了。

在 context 包中定義有 emptyCtx、cancelCtx、timerCtx、valueCtx

四種結構體。其中 cancelCtx、timerCtx 實現了給子協程傳遞取消信號。valueCtx 結構體實現了父協程和子協程傳遞共享數據相關。本節我們重點來看跟傳遞信號相關的 Context。

在上面示例中,我們通過 context.WithTimeout 函數創建了一個帶定時取消功能的 Context 實例,該示例本質上是創建了一個 timerCtx 結構體的實例。在 context 包中還有 WithCancel、WithDeadline 函數也可以創建對應的結構體,其定義如下:

//創建帶有取消功能的Context
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) 

//創建帶有定時自動取消功能的Context
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
//創建帶有定時自動取消功能的Context
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

對應的函數創建的結構體及該實例所實現的功能的主要特點如下圖所示:

在圖中我們看到結構體依次是繼承關係。因爲在 cancelCtx 結構體內嵌套了 Context(實際上是 emptyCtx)、timerCtx 結構體內嵌套了 cancelCtx 結構體,可以認爲他們之間存在繼承關係。

通過 WithTimeout 和 WithDealine 函數創建的 Context 實際上都是 timerCtx 結構體,唯一的區別就是 WithDeadline 函數的第二個參數指定的是最後的時間點,而 WithTimeout 函數的第二個參數是一段時間。但 WithDealine 在內部實現中本質上也是將時間點轉換成距離當前的時間段。

3.2 爲什麼 Done 函數返回值是通道

在 Context 接口的定義中我們看到 Done 函數的定義,其返回值是一個輸出通道:

Done() <-chan struct{}

在上面的示例中我們看到的子協程是通過監聽 Context 的 Done() 函數返回的通道來判斷父協程是否發送了取消信號的。當父協程調用取消函數時,該取消函數將該通道關閉。關閉通道相當於是一個廣播信息,當監聽該通道的接收者從通道到中接收完最後一個元素後,接收者都會解除阻塞,並從通道中接收到通道元素類型的零值。

既然父子協程是通過通道傳到信號的。下面我們介紹父協程是如何將信號通過通道傳遞給子協程的。

3.3 父協程是如何取消子協程的

我們發現在 Context 接口中並沒有定義 Cancel 方法。實際上通過 WithCancel 函數創建的一個具有可取消功能的 Context 實例來實現的:

// WithCancel returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed or cancel is called.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
  if parent == nil {
    panic("cannot create context from nil parent")
  }
  c := newCancelCtx(parent)
  propagateCancel(parent, &c)
  return &c, func() { c.cancel(true, Canceled) }
}

WithCancel 函數的返回值有兩個,一個是 ctx,一個是取消函數 cancel。當父協程調用 cancel 函數時,就相當於觸發了關閉的動作,在 cancel 的執行邏輯中會將 ctx 的 done 通道關閉,然後所有監聽該通道的子協程就會收到一個 struct{} 類型的零值,子協程根據此便執行了返回操作。下面是 cancel 函數實現:

// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
  //...
  d, _ := c.done.Load().(chan struct{})//獲取通道
  if d == nil {
    c.done.Store(closedchan)
  } else {
    close(d) //關閉通道done
  }
  //...
}

由源碼可知,cancelCtx 的 cancel 函數執行時會關閉通道 close(d)。

通過 WithCancel 函數構造的 Context,需要開發者自己設定調用取消函數的條件。而在某些場景下需要設定超時時間,比如調用 grpc 服務時設置超時時間,那麼實際上就是在構造 Context 的同時,啓動一個定時任務,當達到設定的定時時間時,就自動調用 cancel 函數即可。這就是 context 包中提供的 WithDeadline 和 WithTimeout 函數來構造的上下文。如下是 WithDeadline 函數的關鍵實現部分:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
  //...
  c := &timerCtx{
    cancelCtx: newCancelCtx(parent),
    deadline:  d,
  }
  propagateCancel(parent, c)
  dur := time.Until(d)
  //...
  if c.err == nil {
        //這裏實現定時器,即dur時間後執行cancel函數
    c.timer = time.AfterFunc(dur, func() {
      c.cancel(true, DeadlineExceeded)
    })
  }
  return c, func() { c.cancel(true, Canceled) }
}

WithTimeout 函數也是將相對時間 timeout 轉換成絕對的時間點 deadline 之後,調用的 WithDeadline 函數。

3.4 爲什麼要通過 WithXXX 函數構造一個樹形結構

很多文章都說,通過 WithXXX 函數基於 Context 會衍生出一個 Context 樹,樹的每個節點都可以有任意多個子節點 Context。如下圖表示:

那爲什麼要構造一個樹形結構呢?我們從處理一個請求時經過的多個協程來角度來理解會更容易一些。當一個請求到來時,該請求會經過很多個協程的處理,而這些協程之間的關係實際上就組成了一個樹形結構。如下圖:

Context 的目的就是爲了在關聯的協程間傳遞信號和共享數據的,而每個協程又只能管理自己的子節點,而不能管理父節點。所以,在整個處理過程中,Context 自然就衍生成了樹形結構。

如上圖所示,main goroutine 能管理其下的所有子節點以及孫子節點,但 goroutine2 只能管理自己的子節點 goroutine2.1 和 goroutine2.2,不能管理和自己並行的其他節點。那麼這些協程節點之間的管理就是通過對應的 Context 來進行傳遞信號和共享值的。

3.5 爲什麼 WithXXX 函數返回的是一個新的 Context 對象

通過 WithXXX 的源碼可以看到,每個衍生函數返回來的都是一個新的 Context 對象,並且都是基於 parent Context 的。以 WithDeadline 爲例,就是返回的一個 timerCtx 新的結構體實例。這是因爲,在 Context 的傳遞過程中,每個協程都能根據自己的需要來定製 Context(例如,在上圖中,main 協程調用 goroutine2 時要求是 600 毫秒完成操作,但 goroutine2 調用 goroutine2.1 時,要求是 500 毫秒內完成操作),而這些修改又不能影響之前已經調用的函數,只能對向下傳遞。所以,通過一個新的 Context 值來進行傳遞。

04 Context 的作用二:協程間共享數據

Context 的另外一個功能就是在協程間共享數據。該功能是通過 WithValue 函數構造的 Context 來實現的。我們看下 WithValue 的實現:

func WithValue(parent Context, key, val interface{}) Context {
  if parent == nil {
    panic("cannot create context from nil parent")
  }
  if key == nil {
    panic("nil key")
  }
  if !reflectlite.TypeOf(key).Comparable() {
    panic("key is not comparable")
  }
  return &valueCtx{parent, key, val}
}

實現代碼很簡短,我們看到最終返回的是一個 valueCtx 結構體實例。其中有兩點:一是 key 的類型必須是可比較的。二是 value 是不能修改的,即具有不可變性。如果需要添加新的值,只能通過 WithValue 基於原有的 Context 再生成一個新的 valueCtx 來攜帶新的 key-value。這也是 Context 的值在傳遞過程中是併發安全的原因。從另外一個角度來說,在獲取一個 key 的值的時候,也是遞歸的一層一層的從下往上查找,如下:

func (c *valueCtx) Value(key interface{}) interface{} {
  if c.key == key {
    return c.val
  }
  return c.Context.Value(key)
}

上面簡單介紹了下在協程間調用的時候是如何通過 Context 共享數據的。

但這裏討論的重點是什麼樣的數據需要通過 Context 來共享,而不是通過傳參的方式。總結下來有以下兩點:

4.1 什麼是請求範圍(request-scoped)內的數據

這個沒有一個明顯的劃定標準。一般的請求範圍的數據就是用來表示該請求的元數據。比如該請求是由誰發出(即 user id),該請求是在哪兒發出的(即 user ip,請求是從該用戶的 ip 位置發出的)。

例如,如果一個日誌對象 logger 是一個單例那麼它也不是一個請求範圍內的數據。但如果該 logger 包含了發送請求的來源信息,以及該請求是否啓動了調試功能的開關信息,那麼該 logger 也可以被認爲是一個請求範圍內的數據。

4.2 使用 Context.Value 的缺點

使用 Context.Value 會對降低函數的可讀性和表達性。例如,下面是使用 Context.Value 來攜帶 token 驗證角色的示例:

func IsAdminUser(ctx context.Context) bool {
  x := token.GetToken(ctx)
  userObject := auth.AuthenticateToken(x)
  return userObject.IsAdmin() || userObject.IsRoot()
}

當用戶調用該函數的時候,僅僅知道該函數帶有一個 Context 類型的參數。但如果要判斷一個用戶是否是 Admin 必須要兩部分要說明:一個是驗證過的 token,一個是認證服務。

我們將該函數的 Context 移除,然後使用參數的方式來重構,如下:

func IsAdminUser(token string, authService AuthService) bool {
  x := token.GetToken(ctx)
  userObject := auth.AuthenticateToken(x)
  return userObject.IsAdmin() || userObject.IsRoot()
}

那麼這個函數的可讀性和表達性就比重構前提高了很多。調用者通過函數簽名就很容易知道要判斷一個用戶是否是 AdminUser,只需要傳入 token 和認證的服務 authService 即可。

4.3 context.Value 的使用場景

一般複雜的項目都會有中間件層以及大量的抽象層。如果將類似 token 或 userid 這樣簡單的參數以參數的方式從第一個函數層層傳遞,那對調用者來說將會是一種噩夢。如果將這樣的元數據通過 Context 來攜帶進行傳遞,將會是比較好的方式。在實際項目中,最常用的就是在中間件中。我們以 iris 爲 web 框架,來看下在中間件中的應用:

package main
import (
  "context"
  "github.com/google/uuid"
  "github.com/kataras/iris/v12"
)
func main() {
  app := iris.New()
  app.Use(RequestIDMiddleware)
  app.Get("/hello", mainHandler)
  app.Listen("localhost:8080", iris.WithOptimizations)
}
func RequestIDMiddleware(c iris.Context) {
  reqID := uuid.New()
  ctx := context.WithValue(c.Request().Context(), "req_id", reqID)
  req := c.Request().Clone(ctx)
  c.ResetRequest(req)
  c.Next()
}
func mainHandler(ctx iris.Context) {
  req_id := ctx.Request().Context().Value("req_id")
  ctx.Writef("Hello request id:%s", req_id)
  return
}

05 總結

context 包是 go 語言中的一個重要的特性。要想正確的在項目中使用 context,理解其背後的工作機制以及設計意圖是非常重要的。context 包定義了一個 API,它提供對截止日期、取消信號和請求範圍值的支持,這些值可以跨 API 以及在 Goroutine 之間傳遞。

參考鏈接:

https://medium.com/@cep21/how-to-correctly-use-context-context-in-go-1-7-8f2c0fafdf39

https://maiyang.me/post/2018-02-12-how-to-correctly-use-context.context-in-golang/

https://levelup.gitconnected.com/context-in-golang-98908f042a57

https://talks.golang.org/2014/gotham-context.slide#16

https://www.ardanlabs.com/blog/2019/09/context-package-semantics-in-go.html

https://www.sohamkamani.com/golang/context-cancellation-and-values/#context-values

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/M8-6VgRYfEypPQcaaXvSfA