您現在的位置:首頁 > 綜合 > 特別關注 > 正文

[Golang]正確使用Context 環球新視野

時間:2023-03-15 12:19:17    來源:騰訊云    

01 為什么要引入Context

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

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

02 什么是context.Context

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


(資料圖片僅供參考)

typeContextinterface{Deadline()(deadlinetime.Time,okbool)Done()<-chanstruct{}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函數也可以創建對應的結構體,其定義如下:

//創建帶有取消功能的Contextfunc WithCancel(parent Context) (ctx Context, cancel CancelFunc) //創建帶有定時自動取消功能的Contextfunc WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)//創建帶有定時自動取消功能的Contextfunc 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自然就衍生成了樹形結構。

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來共享,而不是通過傳參的方式。總結下來有以下兩點:

攜帶的數據作用域必須是在請求范圍內有效的。即該數據隨著請求的產生而產生,隨著請求的結束而結束,不會永久的保存。攜帶的數據不建議是關鍵參數,關鍵參數應顯式的通過參數來傳遞。例如像trace_id之類的,用于維護作用,就適合用在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 mainimport (  "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之間傳遞。

關鍵詞:

上一篇:
下一篇:

凡本網注明“XXX(非中國微山網)提供”的作品,均轉載自其它媒體,轉載目的在于傳遞更多信息,并不代表本網贊同其觀點和其真實性負責。

特別關注