也許是 Go Context 最佳實踐

以下文章來源於董澤潤的技術筆記 ,作者董澤潤

最早 context 是獨立的第三方庫,後來才移到標準庫裏。關於這個庫該不該用有很多爭義,比如 Context should go away for Go 2[1]. 不管爭義多大,本着務實的哲學,所有的開源項目都重度使用,當然也包括業務代碼。

但是我發現並不是每個人都瞭解 context, 從去年到現在就見過兩次因爲錯誤使用導致的問題。每個同學都會踩到坑,今天分享下 context 庫使用的 Dos and Don'ts

原理

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

Context 是一個接口

  1. Deadline ctx 如果在某個時間點關閉的話,返回該值。否則 ok 爲 false

  2. Done 返回一個 channel, 如果超時或是取消就會被關閉,實現消息通訊

  3. Err 如果當前 ctx 超時或被取消了,那麼 Err 返回錯誤

  4. Value 根據某個 key 返回對應的 value, 功能類似字典

目前的實現有 emptyCtx, valueCtx, cancelCtx, timerCtx. 可以基於某個 Parent 派生成 Child Context

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

這是四個常用的派生函數,WithValue 包裝 key/value 返回 valueCtx, 後三個返回兩個值 Context 是 child ctx, CancelFunc 是取消該 ctx 的函數。基於這個特性呢,經過多次派生,context 是一個樹形結構

context tree

如上圖所示,是一個多叉樹。如果 root 調用 cancel 函數那麼所有 children 也都會級聯 cancel, 因爲保存 children 的是一個 map, 也就無所謂先序中序後序了。如果 ctx 1-1 cancel, 那麼他的 children 都會 cancel, 但是 rootctx 1-2 則不會受影響。

業務代碼當調用棧比較深時,就會出現這個多叉樹的形狀,另外 http 庫己經集成了 context, 每個 endpoint 的請求自帶一個從 http 庫派生出來的 child

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
 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
 if c.done == nil {
  c.done = closedchan
 } else {
  close(c.done)
 }
 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()

 if removeFromParent {
  removeChild(c.Context, c)
 }
}

可以通過 cancelCtxcancel 看到原理,級聯 cancel 所有 children

場景

來看一下使用場景吧,以一個標準的 watch etcd 來入手

func watch(ctx context.Context, revision int64) {
 ctx, cancel := context.WithCancel(ctx)
 defer cancel()

 for {
  rch := watcher.Watch(ctx, watchPath, clientv3.WithRev(revision))
  for wresp := range rch {
    ......
      doSomething()
  }

  select {
  case <-ctx.Done():
   // server closed, return
   return
  default:
  }
 }
}

首先基於參數傳進來的 parent ctx 生成了 child ctxcancel 函數。然後 Watch 時傳入 child ctx, 如果此時 parent ctx 被外層 cancel 的話,child ctx 也會被 cancel, rch 會被 etcd clientv3 關閉,然後 for 循環走到 select 邏輯,此時 child ctx 被取消了,所以 <-ctx.Done() 生效,watch 函數返回。

其於 context 可以很好的做到多個 goroutine 協作,超時管理,大大簡化了開發工作。

Bad Cases

那我們看幾個錯誤使用 context 的案例,都非常經典

1. 打印 ctx

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
 c := newCancelCtx(parent)
 propagateCancel(parent, &c)
 return &c, func() { c.cancel(true, Canceled) }
}

// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
 return cancelCtx{Context: parent}
}

WithCancel 爲例子,可以看到 child 同時引用了 parent, 而 propagateCancel 函數的存在,parent 也會引用 child(當 parent 是 cancelCtx 類型時).

如果此時打印 ctx, 就會遞歸調用 String() 方法,就會把 key/value 打印出來。如果此時 value 是非線程安全的,比如 map, 就會引發 concurrent read and write panic.

這個案例就是 http 標準庫的實現 server.go:2906[2] 行代碼,把 http server 保存到 ctx 中

ctx := context.WithValue(baseCtx, ServerContextKey, srv)

最後調用業務層代碼時把 ctx 傳給了用戶

go c.serve(connCtx)

如果此時打印 ctx, 就會打印 http srv 結構體,這裏面就有 map. 感興趣的可以做個實驗,拿 ab 壓測很容易復現。

2. 提前超時

func test(){
 ctx, cancel := context.WithCancel(ctx)
 defer cancel()
  
  doSomething(ctx)
}

func doSomething(ctx){
  go doOthers(ctx)
}

當調用棧較深,多人合作時很容易產生這種情況。其實還是沒明白 ctx cancel 工作原理,異步 go 出去的業務邏輯需要基於 context.Background() 再派生 child ctx, 否則就會提前超時返回

3. 自定義 ctx

理論上沒必要自定義 ctx, 相比官方實現,自定義有個很大的開銷在於 child 如何響應 parent cancel

// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
  ......
 if p, ok := parentCancelCtx(parent); ok {
  p.mu.Lock()
  if p.err != nil {
  ......
  } else {
  ......
   p.children[child] = struct{}{}
  }
  p.mu.Unlock()
 } else {
  atomic.AddInt32(&goroutines, +1)
  go func() {
   select {
   case <-parent.Done():
    child.cancel(false, parent.Err())
   case <-child.Done():
   }
  }()
 }
}

func parentCancelCtx(parent Context) (*cancelCtx, bool) {
  ......
 p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
 if !ok {
  return nil, false
 }
  ......
 return p, true
}

通過源碼可知,parent 引用 child 有兩種方式,官方 cancelCtx 類型的是用 map 保存。但是非官方的需要開啓 goroutine 去監測。本來業務代碼己經 goroutine 滿天飛了,不加節制的使用只會增加系統負擔。

另外聽說某大公司嫌棄這個 map, 想要使用數組重寫一版:(

原則

最後來總結下 context 使用的幾個原則:

  1. 除了框架層不要使用 WithValue 攜帶業務數據,這個類型是 interface{}, 編譯期無法確定,運行時 assert 有開銷。如果真要攜帶也要用 thread-safe 的數據

  2. 一定不要打印 context, 尤其是從 http 標準庫派生出來的,誰知道里面存了什麼

  3. context 做爲第一個參數傳給函數,而不是當成結構體的成員字段來使用 (雖然 etcd 代碼也這麼用)

  4. 儘可能不要自定義用戶層 context,除非收益巨大

  5. 異步 goroutine 邏輯使用 context 時要清楚誰還持有,會不會提前超時

  6. 派生出來的 child ctx 一定要配合 defer cancel() 使用,釋放資源

小結

這次分享就這些,以後面還會分享更多的內容,如果感興趣,可以關注並轉發 (:

參考資料

[1]

Context should go away for Go 2: https://faiface.github.io/post/context-should-go-away-go2/,

[2]

server.go: https://github.com/golang/go/blob/master/src/net/http/server.go#L2878,


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