小白也能看懂的 context 包詳解:從入門到精通

前言

哈嘍,大家好,我是asong。今天想與大家分享context包,經過一年的沉澱,重新出發,基於 Go1.17.1從源碼角度再次分析,不過這次不同的是,我打算先從入門開始,因爲大多數初學的讀者都想先知道怎麼用,然後纔會關心源碼是如何實現的。

相信大家在日常工作開發中一定會看到這樣的代碼:

func a1(ctx context ...){
  b1(ctx)
}
func b1(ctx context ...){
  c1(ctx)
}
func c1(ctx context ...)

context被當作第一個參數(官方建議),並且不斷透傳下去,基本一個項目代碼中到處都是context,但是你們真的知道它有何作用嗎以及它是如何起作用的嗎?我記得我第一次接觸context時,同事都說這個用來做併發控制的,可以設置超時時間,超時就會取消往下執行,快速返回,我就單純的認爲只要函數中帶着context參數往下傳遞就可以做到超時取消,快速返回。相信大多數初學者也都是和我一個想法,其實這是一個錯誤的思想,其取消機制採用的也是通知機制,單純的透傳並不會起作用,比如你這樣寫代碼:

func main()  {
 ctx,cancel := context.WithTimeout(context.Background(),10 * time.Second)
 defer cancel()
 go Monitor(ctx)

 time.Sleep(20 * time.Second)
}

func Monitor(ctx context.Context)  {
 for {
  fmt.Print("monitor")
 }
}

即使context透傳下去了,沒有監聽取消信號也是不起任何作用的。所以瞭解context的使用還是很有必要的,本文就先從使用開始,逐步解析Go語言的context包,下面我們就開始嘍!!!

context包的起源與作用

看官方博客我們可以知道context包是在go1.7版本中引入到標準庫中的:

context可以用來在goroutine之間傳遞上下文信息,相同的context可以傳遞給運行在不同goroutine中的函數,上下文對於多個goroutine同時使用是安全的,context包定義了上下文類型,可以使用backgroundTODO創建一個上下文,在函數調用鏈之間傳播context,也可以使用WithDeadlineWithTimeoutWithCancelWithValue 創建的修改副本替換它,聽起來有點繞,其實總結起就是一句話:context的作用就是在不同的goroutine之間同步請求特定的數據、取消信號以及處理請求的截止日期。

目前我們常用的一些庫都是支持context的,例如gindatabase/sql等庫都是支持context的,這樣更方便我們做併發控制了,只要在服務器入口創建一個context上下文,不斷透傳下去即可。

context的使用

創建context

context包主要提供了兩種方式創建context:

這兩個函數其實只是互爲別名,沒有差別,官方給的定義是:

所以在大多數情況下,我們都使用context.Background作爲起始的上下文向下傳遞。

上面的兩種方式是創建根context,不具備任何功能,具體實踐還是要依靠context包提供的With系列函數來進行派生:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

這四個函數都要基於父Context衍生,通過這些函數,就創建了一顆 Context 樹,樹的每個節點都可以有任意多個子節點,節點層級可以有任意多個,畫個圖表示一下:

基於一個父Context可以隨意衍生,其實這就是一個Context樹,樹的每個節點都可以有任意多個子節點,節點層級可以有任意多個,每個子節點都依賴於其父節點,例如上圖,我們可以基於Context.Background衍生出四個子contextctx1.0-cancelctx2.0-deadlinectx3.0-timeoutctx4.0-withvalue,這四個子context還可以作爲父context繼續向下衍生,即使其中ctx1.0-cancel                                    節點取消了,也不影響其他三個父節點分支。

創建context方法和context的衍生方法就這些,下面我們就一個一個來看一下他們如何被使用。

WithValue攜帶數據

我們日常在業務開發中都希望能有一個trace_id能串聯所有的日誌,這就需要我們打印日誌時能夠獲取到這個trace_id,在python中我們可以用gevent.local來傳遞,在java中我們可以用ThreadLocal來傳遞,在Go語言中我們就可以使用Context來傳遞,通過使用WithValue來創建一個攜帶trace_idcontext,然後不斷透傳下去,打印日誌時輸出即可,來看使用例子:

const (
 KEY = "trace_id"
)

func NewRequestID() string {
 return strings.Replace(uuid.New().String()"-""", -1)
}

func NewContextWithTraceID() context.Context {
 ctx := context.WithValue(context.Background(), KEY,NewRequestID())
 return ctx
}

func PrintLog(ctx context.Context, message string)  {
 fmt.Printf("%s|info|trace_id=%s|%s",time.Now().Format("2006-01-02 15:04:05") , GetContextValue(ctx, KEY), message)
}

func GetContextValue(ctx context.Context,k string)  string{
 v, ok := ctx.Value(k).(string)
 if !ok{
  return ""
 }
 return v
}

func ProcessEnter(ctx context.Context) {
 PrintLog(ctx, "Golang夢工廠")
}


func main()  {
 ProcessEnter(NewContextWithTraceID())
}

輸出結果:

2021-10-31 15:13:25|info|trace_id=7572e295351e478e91b1ba0fc37886c0|Golang夢工廠
Process finished with the exit code 0

我們基於context.Background創建一個攜帶trace_idctx,然後通過context樹一起傳遞,從中派生的任何context都會獲取此值,我們最後打印日誌的時候就可以從ctx中取值輸出到日誌中。目前一些RPC框架都是支持了Context,所以trace_id的向下傳遞就更方便了。

在使用withVaule時要注意四個事項:

超時控制

通常健壯的程序都是要設置超時時間的,避免因爲服務端長時間響應消耗資源,所以一些web框架或rpc框架都會採用withTimeout或者withDeadline來做超時控制,當一次請求到達我們設置的超時時間,就會及時取消,不在往下執行。withTimeoutwithDeadline作用是一樣的,就是傳遞的時間參數不同而已,他們都會通過傳入的時間來自動取消Context,這裏要注意的是他們都會返回一個cancelFunc方法,通過調用這個方法可以達到提前進行取消,不過在使用的過程還是建議在自動取消後也調用cancelFunc去停止定時減少不必要的資源浪費。

withTimeoutWithDeadline不同在於WithTimeout將持續時間作爲參數輸入而不是時間對象,這兩個方法使用哪個都是一樣的,看業務場景和個人習慣了,因爲本質withTimout內部也是調用的WithDeadline

現在我們就舉個例子來試用一下超時控制,現在我們就模擬一個請求寫兩個例子:

func main()  {
 HttpHandler()
}

func NewContextWithTimeout() (context.Context,context.CancelFunc) {
 return context.WithTimeout(context.Background(), 3 * time.Second)
}

func HttpHandler()  {
 ctx, cancel := NewContextWithTimeout()
 defer cancel()
 deal(ctx)
}

func deal(ctx context.Context)  {
 for i:=0; i< 10; i++ {
  time.Sleep(1*time.Second)
  select {
  case <- ctx.Done():
   fmt.Println(ctx.Err())
   return
  default:
   fmt.Printf("deal time is %d\n", i)
  }
 }
}

輸出結果:

deal time is 0
deal time is 1
context deadline exceeded
func main()  {
 HttpHandler1()
}

func NewContextWithTimeout1() (context.Context,context.CancelFunc) {
 return context.WithTimeout(context.Background(), 3 * time.Second)
}

func HttpHandler1()  {
 ctx, cancel := NewContextWithTimeout1()
 defer cancel()
 deal1(ctx, cancel)
}

func deal1(ctx context.Context, cancel context.CancelFunc)  {
 for i:=0; i< 10; i++ {
  time.Sleep(1*time.Second)
  select {
  case <- ctx.Done():
   fmt.Println(ctx.Err())
   return
  default:
   fmt.Printf("deal time is %d\n", i)
   cancel()
  }
 }
}

輸出結果:

deal time is 0
context canceled

使用起來還是比較容易的,既可以超時自動取消,又可以手動控制取消。這裏大家要記的一個坑,就是我們往從請求入口透傳的調用鏈路中的context是攜帶超時時間的,如果我們想在其中單獨開一個 goroutine 去處理其他的事情並且不會隨着請求結束後而被取消的話,那麼傳遞的context要基於context.Background或者context.TODO重新衍生一個傳遞,否決就會和預期不符合了,可以看一下我之前的一篇踩坑文章:context 使用不當引發的一個 bug

withCancel取消控制

日常業務開發中我們往往爲了完成一個複雜的需求會開多個gouroutine去做一些事情,這就導致我們會在一次請求中開了多個goroutine確無法控制他們,這時我們就可以使用withCancel來衍生一個context傳遞到不同的goroutine中,當我想讓這些goroutine停止運行,就可以調用cancel來進行取消。

來看一個例子:

func main()  {
 ctx,cancel := context.WithCancel(context.Background())
 go Speak(ctx)
 time.Sleep(10*time.Second)
 cancel()
 time.Sleep(1*time.Second)
}

func Speak(ctx context.Context)  {
 for range time.Tick(time.Second){
  select {
  case <- ctx.Done():
   fmt.Println("我要閉嘴了")
   return
  default:
   fmt.Println("balabalabalabala")
  }
 }
}

運行結果:

balabalabalabala
....省略
balabalabalabala
我要閉嘴了

我們使用withCancel創建一個基於Background的 ctx,然後啓動一個講話程序,每隔 1s 說一話,main函數在 10s 後執行cancel,那麼speak檢測到取消信號就會退出。

自定義Context

因爲Context本質是一個接口,所以我們可以通過實現Context達到自定義Context的目的,一般在實現Web框架或RPC框架往往採用這種形式,比如gin框架的Context就是自己有封裝了一層,具體代碼和實現就貼在這裏,有興趣可以看一下gin.Context是如何實現的。

源碼賞析

Context 其實就是一個接口,定義了四個方法:

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

這個接口主要被三個類繼承實現,分別是emptyCtxValueCtxcancelCtx,採用匿名接口的寫法,這樣可以對任意實現了該接口的類型進行重寫。

下面我們就從創建到使用來層層分析。

創建根Context

其在我們調用context.Backgroundcontext.TODO時創建的對象就是empty

var (
 background = new(emptyCtx)
 todo       = new(emptyCtx)
)

func Background() Context {
 return background
}

func TODO() Context {
 return todo
}

BackgroundTODO還是一模一樣的,官方說:background它通常由主函數、初始化和測試使用,並作爲傳入請求的頂級上下文;TODO是當不清楚要使用哪個 Context 或尚不可用時,代碼應使用 context.TODO,後續在在進行替換掉,歸根結底就是語義不同而已。

emptyCtx

emptyCtx主要是給我們創建根Context時使用的,其實現方法也是一個空結構,實際源代碼長這樣:

type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
 return
}

func (*emptyCtx) Done() <-chan struct{} {
 return nil
}

func (*emptyCtx) Err() error {
 return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
 return nil
}

func (e *emptyCtx) String() string {
 switch e {
 case background:
  return "context.Background"
 case todo:
  return "context.TODO"
 }
 return "unknown empty Context"
}

WithValue的實現

withValue內部主要就是調用valueCtx類:

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

valueCtx目的就是爲Context攜帶鍵值對,因爲它採用匿名接口的繼承實現方式,他會繼承父Context,也就相當於嵌入Context當中了

type valueCtx struct {
 Context
 key, val interface{}
}

實現了String方法輸出Context和攜帶的鍵值對信息:

func (c *valueCtx) String() string {
 return contextName(c.Context) + ".WithValue(type " +
  reflectlite.TypeOf(c.key).String() +
  ", val " + stringify(c.val) + ")"
}

實現Value方法來存儲鍵值對:

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

看圖來理解一下:

所以我們在調用Context中的Value方法時會層層向上調用直到最終的根節點,中間要是找到了key就會返回,否會就會找到最終的emptyCtx返回nil

WithCancel的實現

我們來看一下WithCancel的入口函數源代碼:

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

這個函數執行步驟如下:

我們先分析一下cancelCtx這個類。

cancelCtx

cancelCtx繼承了Context,也實現了接口canceler:

type cancelCtx struct {
 Context

 mu       sync.Mutex            // protects following fields
 done     atomic.Value          // of chan struct{}, created lazily, closed by first cancel call
 children map[canceler]struct{} // set to nil by the first cancel call
 err      error                 // set to non-nil by the first cancel call
}

字短解釋:

這裏實現了Done方法,返回的是一個只讀的channel,目的就是我們在外部可以通過這個阻塞的channel等待通知信號。

具體代碼就不貼了。我們先返回去看propagateCancel是如何做構建父子Context之間的關聯。

propagateCancel方法

代碼有點長,解釋有點麻煩,我把註釋添加到代碼中看起來比較直觀:

func propagateCancel(parent Context, child canceler) {
  // 如果返回nil,說明當前父`context`從來不會被取消,是一個空節點,直接返回即可。
 done := parent.Done()
 if done == nil {
  return // parent is never canceled
 }

  // 提前判斷一個父context是否被取消,如果取消了也不需要構建關聯了,
  // 把當前子節點取消掉並返回
 select {
 case <-done:
  // parent is already canceled
  child.cancel(false, parent.Err())
  return
 default:
 }

  // 這裏目的就是找到可以“掛”、“取消”的context
 if p, ok := parentCancelCtx(parent); ok {
  p.mu.Lock()
    // 找到了可以“掛”、“取消”的context,但是已經被取消了,那麼這個子節點也不需要
    // 繼續掛靠了,取消即可
  if p.err != nil {
   child.cancel(false, p.err)
  } else {
      // 將當前節點掛到父節點的childrn map中,外面調用cancel時可以層層取消
   if p.children == nil {
        // 這裏因爲childer節點也會變成父節點,所以需要初始化map結構
    p.children = make(map[canceler]struct{})
   }
   p.children[child] = struct{}{}
  }
  p.mu.Unlock()
 } else {
    // 沒有找到可“掛”,“取消”的父節點掛載,那麼就開一個goroutine
  atomic.AddInt32(&goroutines, +1)
  go func() {
   select {
   case <-parent.Done():
    child.cancel(false, parent.Err())
   case <-child.Done():
   }
  }()
 }
}

這段代碼真正產生疑惑的是這個 if、else 分支。不看代碼了,直接說爲什麼吧。因爲我們可以自己定製context,把context塞進一個結構時,就會導致找不到可取消的父節點,只能重新起一個協程做監聽。

對這塊有迷惑的推薦閱讀饒大大文章:深度解密 Go 語言之 context,定能爲你排憂解惑。

cancel方法

最後我們再來看一下返回的cancel方法是如何實現,這個方法會關閉上下文中的 Channel 並向所有的子上下文同步取消信號:

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
  // 取消時傳入的error信息不能爲nil, context定義了默認error:var Canceled = errors.New("context canceled")
 if err == nil {
  panic("context: internal error: missing cancel error")
 }
  // 已經有錯誤信息了,說明當前節點已經被取消過了
 c.mu.Lock()
 if c.err != nil {
  c.mu.Unlock()
  return // already canceled
 }
  
 c.err = err
  // 用來關閉channel,通知其他協程
 d, _ := c.done.Load().(chan struct{})
 if d == nil {
  c.done.Store(closedchan)
 } else {
  close(d)
 }
  // 當前節點向下取消,遍歷它的所有子節點,然後取消
 for child := range c.children {
  // NOTE: acquiring the child's lock while holding parent's lock.
  child.cancel(false, err)
 }
  // 節點置空
 c.children = nil
 c.mu.Unlock()
  // 把當前節點從父節點中移除,只有在外部父節點調用時纔會傳true
  // 其他都是傳false,內部調用都會因爲c.children = nil被剔除出去
 if removeFromParent {
  removeChild(c.Context, c)
 }
}

到這裏整個WithCancel方法源碼就分析好了,通過源碼我們可以知道cancel方法可以被重複調用,是冪等的。

withDeadlineWithTimeout的實現

先看WithTimeout方法,它內部就是調用的WithDeadline方法:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
 return WithDeadline(parent, time.Now().Add(timeout))
}

所以我們重點來看withDeadline是如何實現的:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
  // 不能爲空`context`創建衍生context
 if parent == nil {
  panic("cannot create context from nil parent")
 }
  
  // 當父context的結束時間早於要設置的時間,則不需要再去單獨處理子節點的定時器了
 if cur, ok := parent.Deadline(); ok && cur.Before(d) {
  // The current deadline is already sooner than the new one.
  return WithCancel(parent)
 }
  // 創建一個timerCtx對象
 c := &timerCtx{
  cancelCtx: newCancelCtx(parent),
  deadline:  d,
 }
  // 將當前節點掛到父節點上
 propagateCancel(parent, c)
  
  // 獲取過期時間
 dur := time.Until(d)
  // 當前時間已經過期了則直接取消
 if dur <= 0 {
  c.cancel(true, DeadlineExceeded) // deadline has already passed
  return c, func() { c.cancel(false, Canceled) }
 }
 c.mu.Lock()
 defer c.mu.Unlock()
  // 如果沒被取消,則直接添加一個定時器,定時去取消
 if c.err == nil {
  c.timer = time.AfterFunc(dur, func() {
   c.cancel(true, DeadlineExceeded)
  })
 }
 return c, func() { c.cancel(true, Canceled) }
}

withDeadline相較於withCancel方法也就多了一個定時器去定時調用cancel方法,這個cancel方法在timerCtx類中進行了重寫,我們先來看一下timerCtx類,他是基於cancelCtx的,多了兩個字段:

type timerCtx struct {
 cancelCtx
 timer *time.Timer // Under cancelCtx.mu.

 deadline time.Time
}

timerCtx實現的cancel方法,內部也是調用了cancelCtxcancel方法取消:

func (c *timerCtx) cancel(removeFromParent bool, err error) {
  // 調用cancelCtx的cancel方法取消掉子節點context
 c.cancelCtx.cancel(false, err)
  // 從父context移除放到了這裏來做
 if removeFromParent {
  // Remove this timerCtx from its parent cancelCtx's children.
  removeChild(c.cancelCtx.Context, c)
 }
  // 停掉定時器,釋放資源
 c.mu.Lock()
 if c.timer != nil {
  c.timer.Stop()
  c.timer = nil
 }
 c.mu.Unlock()
}

終於源碼部分我們就看完了,現在你何感想?

context的優缺點

context包被設計出來就是做併發控制的,這個包有利有弊,個人總結了幾個優缺點,歡迎評論區補充。

缺點

優點

參考文章

https://pkg.go.dev/context@go1.7beta1#Background https://studygolang.com/articles/21531 https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-context/ https://www.cnblogs.com/qcrao-2018/p/11007503.html https://segmentfault.com/a/1190000039294140 https://www.flysnow.org/2017/05/12/go-in-action-go-context.html

總結

context雖然在使用上醜陋了一點,但是他卻能解決很多問題,日常業務開發中離不開context的使用,不過也別使用錯了context,其取消也採用的channel通知,所以代碼中還有要有監聽代碼來監聽取消信號,這點也是經常被廣大初學者容易忽視的一個點。

文中示例已上傳github:https://github.com/asong2020/Golang_Dream/tree/master/code_demo/context_example

好啦,本文到這裏就結束了,我是asong,我們下期見。

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