Golang Context 原理與實戰

本文讓我們一起來學習 golang Context 的使用和標準庫中的Context的實現。

golang context 包 一開始只是 Google 內部使用的一個 Golang 包,在 Golang 1.7的版本中正式被引入標準庫。下面開始學習。

簡單介紹

在學習 context 包之前,先看幾種日常開發中經常會碰到的業務場景:

  1. 業務需要對訪問的數據庫,RPC ,或API接口,為了防止這些依賴導致我們的服務超時,需要針對性的做超時控制。
  2. 為了詳細瞭解服務性能,記錄詳細的調用鏈Log。

上面兩種場景在web中是比較常見的,context 包就是為了方便我們應對此類場景而使用的。

接下來, 我們首先學習 context 包有哪些方法供我們使用;接著舉一些例子,使用 context 包應用在我們上述場景中去解決我們遇到的問題;最後從源碼角度學習 context 內部實現,瞭解 context 的實現原理。

Context 包

Context 定義

context 包中實現了多種 Context 對象。Context 是一個接口,用來描述一個程序的上下文。接口中提供了四個抽象的方法,定義如下:

<code>type Context interface {
  Deadline() (deadline time.Time, ok bool)
  Done() /<code>
  • Deadline() 返回的是上下文的截至時間,如果沒有設定,ok 為 false
  • Done() 當執行的上下文被取消後,Done返回的chan就會被close。如果這個上下文不會被取消,返回nil
  • Err() 有幾種情況:如果Done() 返回 chan 沒有關閉,返回nil如果Done() 返回的chan 關閉了, Err 返回一個非nil的值,解釋為什麼會Done()如果Canceled,返回 "Canceled"如果超過了 Deadline,返回 "DeadlineEsceeded"
  • Value(key) 返回上下文中 key 對應的 value 值

Context 構造

為了使用 Context,我們需要了解 Context 是怎麼構造的。

Context 提供了兩個方法做初始化:

<code>func Background() Context{}
func TODO() Context {}/<code>

上面方法均會返回空的 Context,但是 Background 一般是所有 Context 的基礎,所有 Context 的源頭都應該是它。TODO 方法一般用於當傳入的方法不確定是哪種類型的 Context 時,為了避免 Context 的參數為nil而初始化的 Context。

其他的 Context 都是基於已經構造好的 Context 來實現的。一個 Context 可以派生多個子 context。基於 Context 派生新Context 的方法如下:

<code>func WithCancel(parent Context) (ctx Context, cancel CancelFunc){}
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {}
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {}/<code>

上面三種方法比較類似,均會基於 parent Context 生成一個子 ctx,以及一個 Cancel 方法。如果調用了cancel 方法,ctx 以及基於 ctx 構造的子 context 都會被取消。不同點在於 WithCancel 必需要手動調用 cancel 方法,WithDeadline

可以設置一個時間點,WithTimeout 是設置調用的持續時間,到指定時間後,會調用 cancel 做取消操作。

除了上面的構造方式,還有一類是用來創建傳遞 traceId, token 等重要數據的 Context。

<code>func WithValue(parent Context, key, val interface{}) Context {}/<code>

withValue 會構造一個新的context,新的context 會包含一對 Key-Value 數據,可以通過Context.Value(Key) 獲取存在 ctx 中的 Value 值。

通過上面的理解可以直到,Context 是一個樹狀結構,一個 Context 可以派生出多個不一樣的Context。我們大概可以畫一個如下的樹狀圖:

Golang Context 原理與實戰

一個background,衍生出一個帶有traceId的valueCtx,然後valueCtx衍生出一個帶有cancelCtx

的context。最終在一些db查詢,http查詢,rpc沙遜等異步調用中體現。如果出現超時,直接把這些異步調用取消,減少消耗的資源,我們也可以在調用時,通過Value 方法拿到traceId,並記錄下對應請求的數據。

當然,除了上面的幾種 Context 外,我們也可以基於上述的 Context 接口實現新的Context.

使用方法

下面我們舉幾個例子,學習上面講到的方法。

超時查詢的例子

在做數據庫查詢時,需要對數據的查詢做超時控制,例如:

<code>ctx = context.WithTimeout(context.Background(), time.Second)
rows, err := pool.QueryContext(ctx, "select * from products where id = ?", 100)/<code>

上面的代碼基於 Background 派生出一個帶有超時取消功能的ctx,傳入帶有context查詢的方法中,如果超過1s未返回結果,則取消本次的查詢。使用起來非常方便。為了瞭解查詢內部是如何做到超時取消的,我們看看DB內部是如何使用傳入的ctx的。

在查詢時,需要先從pool中獲取一個db的鏈接,代碼大概如下:

<code>// src/database/sql/sql.go
// func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) *driverConn, error)

// 阻塞從req中獲取鏈接,如果超時,直接返回
select {
case /<code>

req 也是一個chan,是等待鏈接返回的chan,如果Done() 返回的chan 關閉後,則不再關心req的返回了,我們的查詢就超時了。

在做SQL Prepare、SQL Query 等操作時,也會有類似方法:

<code>select {
default:
// 校驗是否已經超時,如果超時直接返回
case /<code>

上面在做查詢時,首先判斷是否已經超時了,如果超時,則直接返回錯誤,否則才進行查詢。

可以看出,在派生出的帶有超時取消功能的 Context 時,內部方法在做異步操作(比如獲取鏈接,查詢等)時會先查看是否已經

Done了,如果Done,說明請求已超時,直接返回錯誤;否則繼續等待,或者做下一步工作。這裡也可以看出,要做到超時控制,需要不斷判斷 Done() 是否已關閉。

鏈路追蹤的例子

在做鏈路追蹤時,Context 也是非常重要的。(所謂鏈路追蹤,是說可以追蹤某一個請求所依賴的模塊,比如db,redis,rpc下游,接口下游等服務,從這些依賴服務中找到請求中的時間消耗)

下面舉一個鏈路追蹤的例子:

<code>// 建議把key 類型不導出,防止被覆蓋
type traceIdKey struct{}{}

// 定義固定的Key
var TraceIdKey = traceIdKey{}

func ServeHTTP(w http.ResponseWriter, req *http.Request){
  // 首先從請求中拿到traceId
  // 可以把traceId 放在header裡,也可以放在body中
  // 還可以自己建立一個 (如果自己是請求源頭的話)
  traceId := getTraceIdFromRequest(req)

  // Key 存入 ctx 中
  ctx := context.WithValue(req.Context(), TraceIdKey, traceId)

  // 設置接口1s 超時
  ctx = context.WithTimeout(ctx, time.Second)

  // query RPC 時可以攜帶 traceId
  repResp := RequestRPC(ctx, ...)

  // query DB 時可以攜帶 traceId
  dbResp := RequestDB(ctx, ...)

  // ...
}

func RequestRPC(ctx context.Context, ...) interface{} {
    // 獲取traceid,在調用rpc時記錄日誌
    traceId, _ := ctx.Value(TraceIdKey)
    // request

    // do log
    return
}/<code>

上述代碼中,當拿到請求後,我們通過req 獲取traceId, 並記錄在ctx中,在調用RPC,DB等時,傳入我們構造的ctx,在後續代碼中,我們可以通過ctx拿到我們存入的traceId,使用traceId 記錄請求的日誌,方便後續做問題定位。

當然,一般情況下,context 不會單純的僅僅是用於 traceId 的記錄,或者超時的控制。很有可能二者兼有之。

如何實現

知其然也需知其所以然。想要充分利用好 Context,我們還需要學習 Context 的實現。下面我們一起學習不同的 Context 是如何實現 Context 接口的,

空上下文

Background(), Empty() 均會返回一個空的 Context emptyCtx。emptyCtx 對象在方法 Deadline(), Done(), Err(), Value(interface{}) 中均會返回nil,String() 方法會返回對應的字符串。這個實現比較簡單,我們這裡暫時不討論。

有取消功能的上下文

WithCancel 構造的context 是一個cancelCtx實例,代碼如下。

<code>type cancelCtx struct {
  Context

  // 互斥鎖,保證context協程安全
  mu       sync.Mutex
  // cancel 的時候,close 這個chan
  done     chan struct{}
  // 派生的context
  children map[canceler]struct{}
  err      error
}/<code>

WithCancel 方法首先會基於 parent 構建一個新的 Context,代碼如下:

<code>func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
  c := newCancelCtx(parent)  // 新的上下文
  propagateCancel(parent, &c) // 掛到parent 上
  return &c, func() { c.cancel(true, Canceled) }
}/<code>

其中,propagateCancel 方法會判斷 parent 是否已經取消,如果取消,則直接調用方法取消;如果沒有取消,會在parent的children 追加一個child。這裡就可以看出,context 樹狀結構的實現。 下面是propateCancel 的實現:

<code>// 把child 掛在到parent 下
func propagateCancel(parent Context, child canceler) {
  // 如果parent 為空,則直接返回
  if parent.Done() == nil {
    return // parent is never canceled
  }
  
  // 獲取parent類型
  if p, ok := parentCancelCtx(parent); ok {
    p.mu.Lock()
    if p.err != nil {
      // parent has already been canceled
      child.cancel(false, p.err)
    } else {
      if p.children == nil {
        p.children = make(map[canceler]struct{})
      }
      p.children[child] = struct{}{}
    }
    p.mu.Unlock()
  } else {
    // 啟動goroutine,等待parent/child Done
    go func() {
      select {
      case /<code>

Done() 實現比較簡單,就是返回一個chan,等待chan 關閉。可以看出 Done 操作是在調用時才會構造 chan done,done 變量是延時初始化的。

<code>func (c *cancelCtx) Done() /<code>

在手動取消 Context 時,會調用 cancelCtx 的 cancel 方法,代碼如下:

<code>func (c *cancelCtx) cancel(removeFromParent bool, err error) {
  // 一些判斷,關閉 ctx.done chan
  // ...
  if c.done == nil {
    c.done = closedchan
  } else {
    close(c.done)
  }

  // 廣播到所有的child,需要cancel goroutine 了
  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()

  // 然後從父context 中,刪除當前的context
  if removeFromParent {
    removeChild(c.Context, c)
  }
}/<code>

這裡可以看到,當執行cancel時,除了會關閉當前的cancel外,還做了兩件事,① 所有的child 都調用cancel方法,② 由於該上下文已經關閉,需要從父上下文中移除當前的上下文。

定時取消功能的上下文

WithDeadline, WithTimeout 提供了實現定時功能的 Context 方法,返回一個timerCtx結構體。WithDeadline 是給定了執行截至時間,WithTimeout 是倒計時時間,WithTImeout 是基於WithDeadline實現的,因此我們僅看其中的WithDeadline

即可。WithDeadline 內部實現是基於cancelCtx 的。相對於 cancelCtx 增加了一個計時器,並記錄了 Deadline 時間點。下面是timerCtx 結構體:

<code>type timerCtx struct {
  cancelCtx
  // 計時器
  timer *time.Timer
  // 截止時間
  deadline time.Time
}/<code>

WithDeadline 的實現:

<code>func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
  // 若父上下文結束時間早於child,
  // 則child直接掛載在parent上下文下即可
  if cur, ok := parent.Deadline(); ok && cur.Before(d) {
    return WithCancel(parent)
  }

  // 創建個timerCtx, 設置deadline
  c := &timerCtx{
    cancelCtx: newCancelCtx(parent),
    deadline:  d,
  }

  // 將context掛在parent 之下
  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 {
    // 設定一個計時器,到時調用cancel
    c.timer = time.AfterFunc(dur, func() {
      c.cancel(true, DeadlineExceeded)
    })
  }
  return c, func() { c.cancel(true, Canceled) }
}/<code>

構造方法中,將新的context 掛在到parent下,並創建了倒計時器定期觸發cancel。

timerCtx 的cancel 操作,和cancelCtx 的cancel 操作是非常類似的。在cancelCtx 的基礎上,做了關閉定時器的操作

<code>func (c *timerCtx) cancel(removeFromParent bool, err error) {
  // 調用cancelCtx 的cancel 方法 關閉chan,並通知子context。
  c.cancelCtx.cancel(false, err)
  // 從parent 中移除
  if removeFromParent {
    removeChild(c.cancelCtx.Context, c)
  }
  c.mu.Lock()
  // 關掉定時器
  if c.timer != nil {
    c.timer.Stop()
    c.timer = nil
  }
  c.mu.Unlock()
}/<code>

timeCtx 的 Done 操作直接複用了cancelCtx 的 Done 操作,直接關閉 chan done 成員。

傳遞值的上下文

WithValue 構造的上下文與上面幾種有區別,其構造的context 原型如下:

<code>type valueCtx struct {
  // 保留了父節點的context
  Context
  key, val interface{}
}/<code>

每個context 包含了一個Key-Value組合。valueCtx 保留了父節點的Context,但沒有像cancelCtx 一樣保留子節點的Context. 下面是valueCtx的構造方法:

<code>func WithValue(parent Context, key, val interface{}) Context {
  if key == nil {
    panic("nil key")
  }
  // key 必須是課比較的,不然無法獲取Value
  if !reflect.TypeOf(key).Comparable() {
    panic("key is not comparable")
  }
  return &valueCtx{parent, key, val}
}/<code>

直接將Key-Value賦值給struct 即可完成構造。下面是獲取Value 的方法:

<code>func (c *valueCtx) Value(key interface{}) interface{} {
  if c.key == key {
    return c.val
  }
  // 從父context 中獲取
  return c.Context.Value(key)
}/<code>

Value 的獲取是採用鏈式獲取的方法。如果當前 Context 中找不到,則從父Context中獲取。如果我們希望一個context 多放幾條數據時,可以保存一個map 數據到 context 中。這裡不建議多次構造context來存放數據。畢竟取數據的成本也是比較高的。

注意事項

最後,在使用中應該注意如下幾點:

  • context.Background 用在請求進來的時候,所有其他context 來源於它。
  • 在傳入的conttext 不確定使用的是那種類型的時候,傳入TODO context (不應該傳入一個nil 的context)
  • context.Value 不應該傳入可選的參數,應該是每個請求都一定會自帶的一些數據。(比如說traceId,授權token 之類的)。在Value 使用時,建議把Key 定義為全局const 變量,並且key 的類型不可導出,防止數據存在衝突。
  • context goroutines 安全。


分享到:


相關文章: