golang中的context包

張浮生發表於2019-04-08

標準庫的context

從設計角度上來講, golang的context包提供了一種父routine對子routine的管理功能. 我的這種理解雖然和網上各種文章中講的不太一樣, 但我認為基本上還是很貼合實際的.

context包中定義了一個很重要的介面, 叫context.Context.它的使用邏輯是這樣的:

  1. 當父routine需要建立一個子routine的時候, 父routine應當先建立一個context.Context的例項, 這個例項中包括的內容有:
    1. 對子routine生命週期的限制: 比如子routine應該什麼時候自殺, 什麼條件下自殺. 在服務端程式設計中, 一個生動的粟子就是: 接收請求的routine在將請求派發給工作routine的時候, 需要告訴工作routine: 超過400ms沒處理完你就給我就地爆炸.
    2. 將一些資料共享給子routine.
    3. 在子routine執行過程中, 通過這個Context例項, 可以干涉子routine的生命週期
  2. 子routine拿到父routine建立的context.Context例項後, 開始幹活, 幹活的過程中, 需要:
    1. 遵守Context例項中關於自身生命週期的約束: 400ms請求沒有處理完, 我要就地爆炸
    2. 在自殺之前將自己自殺的訊息傳遞給Context, 這樣父routine就可以得知自己的生命狀態. 比如我200ms處理完了請求, 我要告訴父routine, 我已經好了
    3. 工作的時候, 如有必要, 從Context中獲取一些必要資料.
    4. 工作結束時, 如有必要, 將一些工作成果傳送給Context, 以讓父routine得知: 比如, 我處理這個請求花費的時間是197ms
    5. 在執行過程中, 從Context接收來自你routine的排程訊號

所以說很顯然:

  1. Context例項是由父routine建立的. 建立之後傳遞給子routine作為行為規範
  2. 子routine一般是不允許操作這個Context例項的. 子routine應當耐心傾聽, 僅在必要的時候, 比如自殺之前, 將一些資訊傳遞給Context
  3. 一個Context的一生, 從生到死, 是和子routine繫結在一起的. 子routine生, Context生, 子routine死, Context
  4. 良好設計的服務端程式, 每個routine都應該有自己的Context. 而既然routine之間有父子關係樹, 那麼顯然所有routine的Context之間也有一坨樹型關係.

我們現在來看context/context.go中是如何實現這套工具的

1 首先是對基本Context的定義

// 定義了一個介面, 名為Context
type Context interface {
    // 返回這個Context的死亡時刻, 如果ok == false, 則這個Context是永生的
    Deadline() (deadline time.Time, ok bool)
    
    // 返回一個channel, 這個channel在Context被Cancel的時候被關閉
    // 如果Context是永生的, 則返回一個nil
    Done() <-chan struct{}
    
    // 在Context活著的時候, (Done()返回的channel還沒被關閉), 它返回nil
    // 在Context死後, (Done()返回的channel被關閉), 它返回一個error例項用以說明:
    //   這個Context是為什麼死掉的, 是被Cancel, 還是自然死亡?
    Err() error
    
    // 返回儲存在Context中的通訊資料
    // 注意: 不要濫用這個介面, 它不是用來給子routine傳遞引數用的!
    Value(key interface{}) interface
}

// 定義了兩個error例項, 併為其中一個例項的error型別定義了三個方法
var Canceled = errors.New("context canceled") // 用以在Context被Cancel時, 從Err()返回
var DeadlineExceeded error = deadlineExceedError{} // 用以在Context自然死亡時, 從Err()返回
type deadlineExceedError struct{}
func (deadlineExceededError) Error() string   { return "context deadline exceeded" } // 實現error介面
func (deadlineExceededError) Timeout() bool   { return true }
func (deadlineExceededError) Temporary() bool { return true }

// 實現了一個Context型別: emptyCtx, 它有以下特點:
//  0. 這個型別不對外公開, 僅通過後面的兩個介面公開它的兩個例項
//  1. 不能被Cancel
//  2. 也從不自然死亡, 它是永生的
//  3. 不同的例項之間需要有不同的地址, 所以它沒有被定義成struct{}, 而是用一個int來替代
//  4. 它內部也不儲存任何資料
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
}

// 定義了兩個emptyCtx的例項, 並寫了兩個介面對外公開這兩個例項
var (
    background = new(emptyCtx)
    todo       = new(emptyCtx)
)

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

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

上面定義了Context的介面規範, 也定義了一個Context介面的實現: emptyCtx, 從程式碼上可以看出來, 標準庫並不公開這個emptyCtx的實現, 你只能從它的公開介面context.Background()context.TODO()來訪問兩個已經例項化的emptyCtx例項.

這兩個例項是用於為頂層routine使用的.下面我們再來看, 可被建立者Cancel的Context是怎麼實現的

2 Context介面的實現: 支援Cancel操作的Context: 非公開類cancelCtx

首先是類定義

type cancelCtx struct {
    Context                        // 他爹

    mu       sync.Mutex            // 一個互斥鎖, 用來保護其它欄位
    done     chan struct{}         // Done()方法的返回值
    children map[canceler]struct{} // 這裡記錄了它的孩子
    err      error                 // Err()方法的返回值
}

我們在上面說了, 由於程式中的routine之間是有父子關係樹存在的, 那麼一個context正常情況下就有可能有孩子, 那麼, 如果當前的routine持有的Context例項是可被Cancel的, 那麼顯然, 它的所有孩子routine, 也應當是可被Cancel的.

這就是為什麼cancelCtx類中有Context欄位和children欄位的原因, 也是為什麼children欄位是一個map[canceler]struct{}型別的原因: key中記錄著所有的孩子, value是沒有意義的, 為什麼這樣寫呢? 因為這裡把map當成C++中的std::set在用!

key的型別canceler是一個介面, 一個表示Context必須可被Cancel的介面:

type canceler interface {
    cancel(removeFromParent bool, err error)
    
    // Context介面中的Done方法
    Done() <-chan struct{}
}

顯然, cancelCtx類本身也是可被Cancel的, 所以它也要實現canceler這個介面

下面是cancelCtx類的方法實現:

// Context.Done的實現: 返回欄位 done
func (c *cancelCtx) Done() <-chan struct{} {
    c.mu.Lock() // 鎖保護done欄位的初始化
    if c.done == nil {
        c.done = make(chan struct{})
    }
    d := c.done
    c.mu.Unlock()
    return d
}
// Context.Err的實現
func (c *cancelCtx) Err() error {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.err
}

// String()方法實現
func (c *cancelCtx) String() string {
    return fmt.Sprintf("%v.WithCancel", c.Context)
}

// canceler.cancel介面實現
// 引數 removeFromParent 指示是否需要把它從它爹的孩子中除名
// 引數 err 將賦值給欄位 err, 以供Context.Err方法返回
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 {   // 如果err欄位有值, 則說明已經被Cancel了
        c.mu.Unlock()
        return // already canceled
    }
    c.err = err
    if c.done == nil {  // 設定c.done, 以供Done方法返回
        c.done = closedchan
    } else {
        close(c.done)
    }
    
    // 挨個cancel它的所有孩子, 子隨父死的時候, 並不除名父子關係
    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)
    }
}

// 這是一個全域性複用的, 被關閉的channel, 用於被Context.Done返回使用
var closedchan = make(chan struct{})

func init() {
    close(closedchan)
}

可以看到, cancelCtx本身並沒有實現所有的Context介面中的方法. 其餘沒有實現的介面是通過Context這個沒有指定欄位名的欄位實現的. 這是go的特殊語法糖: 繼承介面.

在一個型別定義中, 宣告一個介面型別欄位, 並且還不指定欄位的名稱, 這代表

  1. 當前型別必然實現了介面型別
  2. 當呼叫介面方法時, 預設呼叫的是子欄位的方法, 除非當前型別顯式overwrite了一些方法的實現

其實就是一種更為靈活的繼承寫法

我們再來看, 當父routine需要建立一個帶有Cancel功能的Context例項的時候, 應該怎麼辦:

// 首先是定義一個函式指標別名
type CancelFunc func()

// 再就是父routine建立帶Cancel功能的子Context的函式
// 父routine將自己的Context例項傳入, 這個函式會返回子Context(帶Cancel功能)
// 還會返回一個可呼叫物件 cancel, 呼叫這個物件(函式), 就能達到Cancel的功能
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    c := newCancelCtx(parent)   // 建立一個cancelCtx的例項
    propagateCancel(parent, &c)
    return &c, func() { c.cancel(true, Canceled) }
}

// 下面是WithCancel中引用的兩個私有函式的實現

// 建立一個cancelCtx例項
func newCancelCtx(parent Context) cancelCtx {
    return cancelCtx{Context: parent}   // 把爹先記錄下來
}

func propagateCancel(parent Context, child canceler) {
    // 如果父Context是不可Cancel, 什麼也不做
    if parent.Done() == nil {
        return // parent is never canceled
    }
    // 如果父Context本身是可Cancel的
    if p, ok := parentCancelCtx(parent); ok {
        // 進入此分支, 說明父Context是以下三種之一:
        //  1. 是一個cancelCtx, 本身就可被Cancel
        //  2. 是一個timerCtx, timerCtx是canctx的一個子類, 也可被Cancel
        //  3. 是一個valueCtx, valueCtx繼承體系上的某個爹, 是以上兩者之一
        // 那麼p就是那個父Context的繼承體系中的cancelCtx例項
        p.mu.Lock()
        if p.err != nil {
            // 若p已經被Cancel或自然死亡, 作為兒子, 就必須死了
            // 直接呼叫p.cancel
            child.cancel(false, p.err)
        } else {
            // 若p還活著, 就把兒子新增到它的兒子列表中去
            if p.children == nil {
                p.children = make(map[canceler]struct{})
            }
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
        // 進入此分支, 說明父Context雖然可被Cancel
        // 但並不是標準庫中預設的cancelCtx或timerCtx兩種可被Cancel的型別
        // 這意味著這個特殊的父Context, 內部並不能保證記錄了所有兒子的列表
        // 這裡就得新開一個routine, 時刻監視著父Context的生存狀態
        // 一旦父Context死亡, 就立即呼叫child.cancel把兒子弄死
        go func() {
            select {
            case <-parent.Done():   // 如果爹死了, 把孩子弄死
                child.cancel(false, parent.Err())
            case <-child.Done():    // 如果孩子死了, 什麼也不做
            }
        }()
    }
}

// 判斷Context例項是否是一個可被Cancel的型別
// 標準庫中可被Cancel的Context型別共有三種:
//    1. cancelCtx
//    2. timerCtx
// 僅有這兩種
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
    for {
        switch c := parent.(type) {
        case *cancelCtx:
            return c, true
        case *timerCtx:
            return &c.cancelCtx, true
        case *valueCtx:
            parent = c.Context
        default:
            return nil, false
        }
    }
}

3 當你使用WithCancel

一個簡單的例子

這裡來捋一捋, 當你呼叫WithCancel建立一個可被Cancel的Context例項時, 都發生了些什麼:

// 第一步, 建立者routine本身必須持有一個Context
// 這裡假定建立者就是main routine
// 我們呼叫 Background建立一個不可被Cancel, 不會自殺的Context
contextOfMain := ctx.Background()

// 第二步: 呼叫WithCancel建立子Context
contextOfSubRoutine, cancelFuncOfSubRoutine := ctx.WithCancel(contextOfMain)

用起來是十分簡單的, 我們再來捋一捋第二步背後都發生了什麼, 下面是偽碼:

WithCancel(contextOfMain) {
    // 第一步: 呼叫newCancelCtx建立了一個 cancelCtx 私有類的例項, 長這樣:
    c := cancelCtx {
        Context: contextOfMain,
        mu     : 預設值,
        done   : nil, // 雖然現在是nil, 但在呼叫Done()方法時會返回一個make(chan struct{})
        children: nil,
        err    : nil,
    }
    // 第二步, 呼叫propagateCancel(contextOfMain, c)
    // 內部大概發生這樣:
    {
        if contextOfMain.Done() == nil {
            // 什麼也沒有發生
        }
    }
    // 第三步: 返回c, 並且構造一個CancelFunc返回
    // 先是返回c
    return &c // 這裡返回的是c的地址
    // 再是原地構造一個CancelFunc
    func () {
        c.cancel(true, Canceled)
    }
    
    /*
        注意:
            c.cancel呼叫的是cancelCtx.cancel方法
            Canceled是一個全域性變數, 值 == errors.New("context canceled")
    */
}

然後, 你將這個建立好的cancelFuncOfSubRoutine傳遞給新啟動的子routine, 過了幾分鐘, 你呼叫cancelFuncOfSubRoutine()意圖主動Cancel掉子routine的時候, 內部是這樣執行的:

// 其實內部執行的是
contextofSubRoutine.cancel(true, errors.New("context canceled")) {
    
    contextofSubRoutine.mu.Lock()
    contextofSubRoutine.err = errors.New("context canceled")
    contextofSubRoutine.done = closedchan // 這是一個已經被關閉的chan struct{}
    for child := range contextofSubRoutine.children {
        // 遞迴Cancel掉子routine下的所有孫子
        // 而實際上它並沒有孩子, 所以什麼也不做
        child.cancel(false, errors.New("context canceled"))
    }
    contextofSubRoutine.children = nil // 一把火把孫子的屍首全燒了
    contextofSubRoutine.mu.Unlock()
    
    removeChild(contextofSubRoutine.Context, contextofSubRoutine) {
        p, ok := parentCancelCtx(contextOfMainRoutine, contextofSubRoutine)
        // 由於contextOfMainRoutine的型別是emptyCtx
        // 所以parentCancelCtx函式返回的是 nil, false
        所以, 什麼也不做, 就返回了
    }
    
}

一個稍微複雜一點的例子

我們假設當前程式中的routine樹(即是Context樹)關係如下所示:

mainContext // emptyCtx
    |
    \-> subContext // cancelCtx
        |
        \-> subsubContext1 // cancelCtx
        \-> subsubContext2 // cancenCtx
        \-> subsubContext3 // cancelCtx

現在, subContext要建立第四個subsubContext4, 它會這樣做:

// 在subRoutine中
subsubContext4, cancelFunc4 := ctx.WithCancel(subContext) {
    // 內部是這樣的:
    
    // step 1: 呼叫 ctx.newCancelCtx()
    subsubContext4 := &cancelCtx {
        Context: subContext,
        mu     : 預設值,
        done   : nil, // 雖然現在是nil, 但在呼叫Done()時會返回一個make(chan struct{})
        children: nil,
        err    : nil,
    }
    // step 2: 呼叫propagateCancel(subContext, subsubContext4)
    {
        // p, ok := parentCancelCtx(subContext)
        {
            p := subContext
            ok := true
        }
        // 這裡將subsubContext4加到subContext的兒子列表中去
        subContext.mu.Lock()
        subContext[subsubContext4] = struct{}{}
        subContext.mu.Unlock()
    }
    // step 3: 建立CancelFunc
    cancelFunc4 := func() {
        subsubContext4.cancel(true, errors.New("context canceled"))
    }
}

建立結束後, subContext長這樣:

subContext := &cancelCtx {
    Context: mainContext,
    mu     : 預設值,
    done   : nil, // 雖然現在是nil, 但在呼叫Done()時會返回一個make(chan struct{})
    children : {
        subsubContext1 : struct{}{},
        subsubContext2 : struct{}{},
        subsubContext3 : struct{}{},
        subsubContext4 : struct{}{},
    },
    err    : nil
}

然後, 當subRoutine呼叫cancelFunc4意圖弄死subsubRoutine4的時候, 會發生如下:

subsubContext4.cancel(true, errors.New("context canceled")){
    subsubContext4.mu.Lock()
    subsubContext4.err = errors.New("context canceled")
    subsubContext4.done = closedchan
    for child := range subsubContext4.children {
        // subsubContext4並沒有孩子
        // 什麼也不做
    }
    subsubContext4.children = nil
    subsubContext4.mu.Unlock()
    
    removeChild(subsubContext, subsubContext4) {
        // 從subContext的children中刪除 subsubContext4
    }
}

而如果, 在Cancel了subRoutine4後, 主執行緒中直接要Cancel SubRoutine的話, 會發生什麼? 會發生如下:

subContext.cancel(true, errors.New("context canceled")) {
    subContext.mu.Lock()
    subContext.err = errors.New("context canceled")
    subContext.done = closedchan
    for child := range subContext.children {
        // 這裡會呼叫 subsubContext1/2/3的cancel方法
        child.cancel(false, errors.New("context canceled"))
    }
    subContext.children = nil
    subContext.mu.Unlock()
    // 最後一步本身要將subContext從他爹那裡除名
    // 但由於他爹是個emptyCtx, 所以什麼也不做
}

基本把整個cancelCtx的流程理解掉之後, 後面的所謂的帶DeadLine的Context就非常好理解了

4 Context介面的實現: 支援Deadline()操作的Context: 非公開類timerCtx

timerCtx實現了定時器功能: 到達指定時刻, 自殺.

首先是類定義:

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

    deadline time.Time
}

需要注意的是兩點:

  1. 它繼承了cancelCtx
  2. 定時功能是由標準庫的time.Timer實現的

先看它的Deadline()方法的實現, 這個方法就是它的靈魂

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
    return c.deadline, true
}

只是簡單的欄位deadline的getter

它還重寫了canceler.cancel方法:

func (c *timerCtx) cancel(removeFromParent bool, err error) {
    c.cancelCtx.cancel(false, err)
    if removeFromParent {
        // Remove this timerCtx from its parent cancelCtx's children.
        removeChild(c.cancelCtx.Context, c)
    }
    c.mu.Lock()
    if c.timer != nil { // 主要是在Cancel時停掉內部的計時器
        c.timer.Stop()
        c.timer = nil
    }
    c.mu.Unlock()
}

再來看和WithCancel平級的WithDeadLine函式:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
        // 如果父Context也是一個有死期的Context, 並且死期還在兒子想死之前
        // 那麼只是簡單的呼叫WithCancel來給建立一個可被Cancel的cancelCtx即可
        // 這樣, 建立出的子Context呼叫Deadline()方法時, 實質上呼叫的是他爹的Deadline(), 語義上完美完成任務
        return WithCancel(parent)
    }
    
    // 不然, 得帶個定時器, 先把死期記在deadline欄位中
    c := &timerCtx{
        cancelCtx: newCancelCtx(parent),
        deadline:  d,
    }
    // 設定邏輯: 爹死的時候兒子也得死, 並且如果可能, 把兒子記在爹的children欄位中
    propagateCancel(parent, c)
    
    // 如果死期已經過了
    dur := time.Until(d)
    if dur <= 0 {
        // 原地自殺, 但還是要返回這個Context
        c.cancel(true, DeadlineExceeded) // deadline has already passed
        return c, func() { c.cancel(true, 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) }
}

注意:

  1. WithDeadline也返回一個CancelFunc
  2. 如果爹死的比兒子預想的還早, 那隻不過是用爹呼叫WithCancel建立了一個可Cancel的Context
  3. 如果死期在呼叫WithDeadline的時候已經到達了, 那麼依然要給呼叫方返回一個死掉的兒子屍體, 只不過它的Done()Err()會指出這個Context已經死掉了
  4. 定時器的到期回撥, 呼叫的就是canceler.cancel方法

可以看到, timerCtx只是對cancelCtx在功能上的追加. WithDeadline也只是簡單的追加了一個定時器,邏輯還是比較簡單的.

所以,如果到這裡你已經腦子有點亂掉了, 還是要回頭把cancelCtx理清

另外, 這裡還提供了一個名為WithTimeout的函式, 其實與WithDeadline是完全等價的, 實現如下:

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

這裡我們就不再著重分析WithDeadline/WithTimeout的邏輯流程了

5 Context介面的實現: 帶資料共享的非公開類valueCtx

整個定義十分簡單, 就是在Context介面之上, 實現了對資料的儲存而已, 並且只能儲存一個key, 全文如下:

func WithValue(parent Context, key, val interface{}) Context {
   if key == nil {
      panic("nil key")
   }
   if !reflect.TypeOf(key).Comparable() {
      panic("key is not comparable")
   }
   return &valueCtx{parent, key, val}
}

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

func (c *valueCtx) String() string {
   return fmt.Sprintf("%v.WithValue(%#v, %#v)", c.Context, c.key, c.val)
}

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

可以看到, valueCtx將所有重要功能的實現都委託到了父類上, 這在使用時就非常信賴父類, 也就是說, 如果你想僅僅依靠標準庫的這些公開介面, 來直接在主routine下開啟一個, 既帶資料共享, 還帶可Cancel功能的子Context的話, 你只能這樣寫:

mainContext := ctx.Background() // 主routine
// 為了建立一個帶Cancel功能的valueCtx, 首先需要建立一個cancelCtx
tmpContext, subRoutineCancelFunc := ctx.WithCancel(mainContext)
subContext := ctx.WithValue(tmpContext, key, value)

6 總結

標準庫的context包, 只實現了幾個基本的Context介面的實現, 並且還很受限的只能通過公開介面WithXXX來建立, 這很顯然是在鼓勵你做下面的事情:

  1. 在已有的Context介面定義上, 定義你自己的Context實現類.
  2. 不要將過多的邏輯放置在Context中去, 讓它只幹好自己該乾的事情: 那就是父子routine間生命週期的管理

並且顯然context包只實現了Context的語義, 並沒有實現相關的routine的操作: 比如在Cancel時掐死子程式, 在Deadline到期的時候自動自殺等. 這還需要由使用者自行實現.

相關文章