您現(xiàn)在的位置:首頁 > 綜合 > 特別關(guān)注 > 正文

[Golang]正確使用Context 環(huán)球新視野

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

01 為什么要引入Context

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

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

02 什么是context.Context

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


(資料圖片僅供參考)

typeContextinterface{Deadline()(deadlinetime.Time,okbool)Done()<-chanstruct{}Err()error  Value(key interface{}) interface{}}

由定義的接口函數(shù)可知,對于傳遞取消信號的行為我們可以描述為:當(dāng)協(xié)程運行時間達到Deadline時,就會調(diào)用取消函數(shù),關(guān)閉done通道,往done通道中輸入一個空結(jié)構(gòu)體消息struct{}{},這時所有監(jiān)聽done通道的子協(xié)程都會收到該消息,便知道父協(xié)程已經(jīng)關(guān)閉,需要自己也結(jié)束運行

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

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協(xié)程和doSomething函數(shù)之間的唯一關(guān)聯(lián)就是ctx.Done()。當(dāng)子協(xié)程從ctx.Done()通道中接收到輸出時(因為超時自動取消或主動調(diào)用了cancel函數(shù)),即認為是父協(xié)程不再需要子協(xié)程返回的結(jié)果了,子協(xié)程就會直接返回,不再執(zhí)行其他的邏輯。

03 Context的作用一:協(xié)程間傳遞信號

3.1 如何創(chuàng)建帶可以傳遞信號的Context

在開頭處我們得知Context本質(zhì)是一個接口類型。接口類型是需要具體的結(jié)構(gòu)體起來實現(xiàn)的。那我們需要自定義結(jié)構(gòu)體類型來實現(xiàn)這些接口嗎?答案是不需要。因為在context包中已經(jīng)定義好了所需場景的結(jié)構(gòu)體,這些結(jié)構(gòu)體已經(jīng)幫我們實現(xiàn)了Context接口的方法,在項目中就已經(jīng)夠用了。

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

四種結(jié)構(gòu)體。其中cancelCtx、timerCtx實現(xiàn)了給子協(xié)程傳遞取消信號。valueCtx結(jié)構(gòu)體實現(xiàn)了父協(xié)程和子協(xié)程傳遞共享數(shù)據(jù)相關(guān)。本節(jié)我們重點來看跟傳遞信號相關(guān)的Context。

在上面示例中,我們通過context.WithTimeout函數(shù)創(chuàng)建了一個帶定時取消功能的Context實例,該示例本質(zhì)上是創(chuàng)建了一個timerCtx結(jié)構(gòu)體的實例。在context包中還有WithCancel、WithDeadline函數(shù)也可以創(chuàng)建對應(yīng)的結(jié)構(gòu)體,其定義如下:

//創(chuàng)建帶有取消功能的Contextfunc WithCancel(parent Context) (ctx Context, cancel CancelFunc) //創(chuàng)建帶有定時自動取消功能的Contextfunc WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)//創(chuàng)建帶有定時自動取消功能的Contextfunc WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

對應(yīng)的函數(shù)創(chuàng)建的結(jié)構(gòu)體及該實例所實現(xiàn)的功能的主要特點如下圖所示:

在圖中我們看到結(jié)構(gòu)體依次是繼承關(guān)系。因為在cancelCtx結(jié)構(gòu)體內(nèi)嵌套了Context(實際上是emptyCtx)、timerCtx結(jié)構(gòu)體內(nèi)嵌套了cancelCtx結(jié)構(gòu)體,可以認為他們之間存在繼承關(guān)系。

通過WithTimeout和WithDealine函數(shù)創(chuàng)建的Context實際上都是timerCtx結(jié)構(gòu)體,唯一的區(qū)別就是WithDeadline函數(shù)的第二個參數(shù)指定的是最后的時間點,而WithTimeout函數(shù)的第二個參數(shù)是一段時間。但WithDealine在內(nèi)部實現(xiàn)中本質(zhì)上也是將時間點轉(zhuǎn)換成距離當(dāng)前的時間段。

3.2 為什么Done函數(shù)返回值是通道

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

Done() <-chan struct{}

在上面的示例中我們看到的子協(xié)程是通過監(jiān)聽Context的Done()函數(shù)返回的通道來判斷父協(xié)程是否發(fā)送了取消信號的。當(dāng)父協(xié)程調(diào)用取消函數(shù)時,該取消函數(shù)將該通道關(guān)閉。關(guān)閉通道相當(dāng)于是一個廣播信息,當(dāng)監(jiān)聽該通道的接收者從通道到中接收完最后一個元素后,接收者都會解除阻塞,并從通道中接收到通道元素類型的零值。

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

3.3 父協(xié)程是如何取消子協(xié)程的

我們發(fā)現(xiàn)在Context接口中并沒有定義Cancel方法。實際上通過WithCancel函數(shù)創(chuàng)建的一個具有可取消功能的Context實例來實現(xiàn)的:

// 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函數(shù)的返回值有兩個,一個是ctx,一個是取消函數(shù)cancel。當(dāng)父協(xié)程調(diào)用cancel函數(shù)時,就相當(dāng)于觸發(fā)了關(guān)閉的動作,在cancel的執(zhí)行邏輯中會將ctx的done通道關(guān)閉,然后所有監(jiān)聽該通道的子協(xié)程就會收到一個struct{}類型的零值,子協(xié)程根據(jù)此便執(zhí)行了返回操作。下面是cancel函數(shù)實現(xiàn):

// 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) //關(guān)閉通道done  }  //...}

由源碼可知,cancelCtx的cancel函數(shù)執(zhí)行時會關(guān)閉通道close(d)。

通過WithCancel函數(shù)構(gòu)造的Context,需要開發(fā)者自己設(shè)定調(diào)用取消函數(shù)的條件。而在某些場景下需要設(shè)定超時時間,比如調(diào)用grpc服務(wù)時設(shè)置超時時間,那么實際上就是在構(gòu)造Context的同時,啟動一個定時任務(wù),當(dāng)達到設(shè)定的定時時間時,就自動調(diào)用cancel函數(shù)即可。這就是context包中提供的WithDeadline和WithTimeout函數(shù)來構(gòu)造的上下文。如下是WithDeadline函數(shù)的關(guān)鍵實現(xiàn)部分:

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 {        //這里實現(xiàn)定時器,即dur時間后執(zhí)行cancel函數(shù)    c.timer = time.AfterFunc(dur, func() {      c.cancel(true, DeadlineExceeded)    })  }  return c, func() { c.cancel(true, Canceled) }}

WithTimeout函數(shù)也是將相對時間timeout轉(zhuǎn)換成絕對的時間點deadline之后,調(diào)用的WithDeadline函數(shù)。

3.4為什么要通過WithXXX函數(shù)構(gòu)造一個樹形結(jié)構(gòu)

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

那為什么要構(gòu)造一個樹形結(jié)構(gòu)呢?我們從處理一個請求時經(jīng)過的多個協(xié)程來角度來理解會更容易一些。當(dāng)一個請求到來時,該請求會經(jīng)過很多個協(xié)程的處理,而這些協(xié)程之間的關(guān)系實際上就組成了一個樹形結(jié)構(gòu)。如下圖:

Context的目的就是為了在關(guān)聯(lián)的協(xié)程間傳遞信號和共享數(shù)據(jù)的,而每個協(xié)程又只能管理自己的子節(jié)點,而不能管理父節(jié)點。所以,在整個處理過程中,Context自然就衍生成了樹形結(jié)構(gòu)。

3.5為什么WithXXX函數(shù)返回的是一個新的Context對象

通過WithXXX的源碼可以看到,每個衍生函數(shù)返回來的都是一個新的Context對象,并且都是基于parent Context的。以WithDeadline為例,就是返回的一個timerCtx新的結(jié)構(gòu)體實例。這是因為,在Context的傳遞過程中,每個協(xié)程都能根據(jù)自己的需要來定制Context(例如,在上圖中,main協(xié)程調(diào)用goroutine2時要求是600毫秒完成操作,但goroutine2調(diào)用goroutine2.1時,要求是500毫秒內(nèi)完成操作),而這些修改又不能影響之前已經(jīng)調(diào)用的函數(shù),只能對向下傳遞。所以,通過一個新的Context值來進行傳遞。

04 Context的作用二:協(xié)程間共享數(shù)據(jù)

Context的另外一個功能就是在協(xié)程間共享數(shù)據(jù)。該功能是通過WithValue函數(shù)構(gòu)造的Context來實現(xiàn)的。我們看下WithValue的實現(xiàn):

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}}

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

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

上面簡單介紹了下在協(xié)程間調(diào)用的時候是如何通過Context共享數(shù)據(jù)的。

但這里討論的重點是什么樣的數(shù)據(jù)需要通過Context來共享,而不是通過傳參的方式??偨Y(jié)下來有以下兩點:

攜帶的數(shù)據(jù)作用域必須是在請求范圍內(nèi)有效的。即該數(shù)據(jù)隨著請求的產(chǎn)生而產(chǎn)生,隨著請求的結(jié)束而結(jié)束,不會永久的保存。攜帶的數(shù)據(jù)不建議是關(guān)鍵參數(shù),關(guān)鍵參數(shù)應(yīng)顯式的通過參數(shù)來傳遞。例如像trace_id之類的,用于維護作用,就適合用在Context中傳遞。

4.1 什么是請求范圍(request-scoped)內(nèi)的數(shù)據(jù)

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

例如,如果一個日志對象logger是一個單例那么它也不是一個請求范圍內(nèi)的數(shù)據(jù)。但如果該logger包含了發(fā)送請求的來源信息,以及該請求是否啟動了調(diào)試功能的開關(guān)信息,那么該logger也可以被認為是一個請求范圍內(nèi)的數(shù)據(jù)。

4.2 使用Context.Value的缺點

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

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

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

我們將該函數(shù)的Context移除,然后使用參數(shù)的方式來重構(gòu),如下:

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

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

4.3 context.Value的使用場景

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

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 總結(jié)

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

關(guān)鍵詞:

上一篇:
下一篇:

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

特別關(guān)注