深入理解golang:Context

九卷發表於2020-10-10

一、背景

在golang中,最主要的一個概念就是併發協程 goroutine,它只需用一個關鍵字 go 就可以開起一個協程,並執行。

一個單獨的 goroutine執行,倒也沒什麼問題。如果是一個goroutine衍生了多個goroutine,並且它們之間還需要互動-比如傳輸資料,那彼此怎麼傳輸資料呢?如果一個子goroutine取消了,要取消跟其相關的goroutine,怎麼樣才可以做到?

比如說:在go web伺服器中,每個請求request都是在一個單獨的goroutine進行,這些
goroutine可能又開啟其他的goroutine進行其他操作,那麼多個goroutine之間怎麼傳輸資料、遇到了問題怎麼取消goroutine?

有時在程式開發中,每個請求用一個goroutine去處理程式,然而,處理程式時往往還需要其他的goroutine去訪問後端資料資源,比如資料庫、RPC服務等,這些goroutine都在處理同一個請求,所以他們需要訪問一些共享資源,如使用者身份資訊、認證token等,如果請求超時或取消,與此請求相關的所有goroutine都應該退出並釋放資源。

由於golang裡沒有像C語言中執行緒id類似的goroutine id,所以不能通過id直接關閉goroutine。但是有其他的方法。

解決方法:

  • 用時間來表示過期、超時
  • 用訊號來通知請求該停止了
  • 用channel通知請求結束

為此,golang給我們提供了一個簡單的操作包:Context 包。

二、Context是什麼

golang中的Context包,是專門用來簡化對於處理單個請求衍生出多個goroutine,goroutine之間傳輸資料、取消goroutine、超時控制等相關操作的一個包。

三、Context功能

  • 3.1 控制goroutine退出
    • 及時退出 WithCancel
    • 時間點退出 WithDeadline
    • 時間間隔退出 WithTimeout
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

WithCancel,繫結一個parent,返回一個cancelCtx的Context,用返回的 CancelFunc 就可以主動關閉Context。一旦cancel被呼叫,即取消該建立的Context。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

WithDeadline,帶有效期的cancelCtx的Context,即到達指定時間點呼叫CancelFunc方法才會執行

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithTimeout,帶有超時時間的cancelCtx的Context,它是WithDeadline的封裝,只不過WithTimeout為時間間隔,Deadline為時間點。

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}
  • 3.2 設定值
func WithValue(parent Context, key, val interface{})

四、原始碼分析

go version go1.13.9

4.1 整體程式分析

/src/context/context.go

  • 重要介面

    • Context:定義了Context介面的4個方法。
    • canceler:context介面取消,定義了2個方法。
  • 重要結構體

    • emptyCtx:實現了Context介面,它是個空的context,它永遠不會被取消,沒有值,沒有deadline。其主要作為context.Background()context.TODO()返回這種根context或者不做任何操作的context。如果用父子關係來理解,emptyCtx就是用來建立父context。
    • cancelCtx:可以被取消
    • timerCtx:超時會被取消
    • valueCtx:可以儲存k-v鍵值資料
  • 重要函式

    • Backgroud:返回一個空的context,常用作根context
    • TODO:返回一個空的context,常用語重構時期,沒有合適的context可用
    • newCancenCtx:建立一個可取消的context
    • parentCancelCtx:找到第一個可取消的父節點
    • WithCancel:基於父contxt,生成一個可取消的context
    • WithDeadline:建立一個帶有截止時間的context
    • WithTimeout:建立一個帶有過期時間的context
    • WithValue:建立一個儲存鍵值對k-v的context

Background 與 TODO 用法有啥區別呢?

看函式其實它們倆沒多大區別,只是使用和語義上有點區別:

  1. Background:是上下文預設值,所有其他上下文都應該從它衍生出來
  2. TODO:只是在不確定該使用哪種上下文時使用

4.2 Context介面

type Context interface {
    // Deadline返回一個到期的timer定時器,以及當前是否以及到期
    Deadline() (deadline time.Time, ok bool)

    // Done在當前上下文完成後返回一個關閉的通道,代表當前context應該被取消,以便goroutine進行清理工作
    // WithCancel:負責在cancel被呼叫的時候關閉Done
    // WithDeadline: 負責在最後其期限過期時關閉Done
    // WithTimeout:負責超時後關閉done
    Done() <-chan struct{}

    // 如果Done通道沒有被關閉則返回nil
    // 否則則會返回一個具體的錯誤
    // Canceled 被取消
    // DeadlineExceeded 過期
    Err() error
    
    // 返回對應key的value
    Value(key interface{}) interface{}
}
  • Done():
    返回一個channel,可以表示 context 被取消的訊號。
    當channel被關閉或者到了deadline時,返回一個被關閉的channel。這是一個只讀channel。根據golang裡相關知識,讀取被關閉的channel會讀取相應的零值。並且原始碼裡沒有地方會向這個 channel 裡面塞入值,因此在子協程裡讀這個 channel,除非被關閉,否則讀不出任何東西。也正是利用這一點,子協程從channel裡讀出了值(零值)後,就可以做一些清理工作,儘快退出。
  • Deadline():
    主要用於設定超時時間的Context上,它的返回值(返回父任務設定的超時時間)用於表示該Context取消的時間點,通過這個時間,就可以判斷接下來的操作。比如超時,可以取消操作。
  • Value():
    獲取前面設定的key對於的value值
  • Err():
    返回一個錯誤,表示channel被關閉的原因。比如是被取消,還是超時

4.3 emptyCtx結構體

emptyCtx是一個不會被取消、沒有到期時間、沒有值、不會返回錯誤的context的實現,其主要作為context.Background()context.TODO()返回這種根context或者不做任何操作的context。如果用父子關係來理解,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"
}

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

4.4 cancelCtx結構體

cancelCtx struct:

type cancelCtx struct {
    Context
    mu       sync.Mutex            // protects following fields
    done     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
}
  • Context:cancelCtx嵌入一個Context介面物件,作為一個匿名欄位。這個Context就是父context
  • mu:保護之後的欄位
  • children:內部通過這個children儲存所有可以被取消的context的介面,到後面,如果當前context被取消的時候,只需要呼叫所有canceler介面的context就可以實現當前呼叫鏈的取消
  • done:取消的訊號
  • err:錯誤資訊

Done() 函式

func (c *cancelCtx) Done() <-chan struct{} {
    c.mu.Lock()
    if c.done == nil {
        c.done = make(chan struct{})
    }
    d := c.done
    c.mu.Unlock()
    return d
}

函式返回一個只讀channel,而且沒有地方向這個channel裡寫資料。所以直接呼叫這個只讀channel會被阻塞。一般通過搭配 select 來使用。一旦關閉,就會立即讀出零值。

cancel() 函式

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    if err == nil {// 必須傳一個err值,後面判斷用
        panic("context: internal error: missing cancel error")
    }
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // already canceled 已經被其他協程取消了
    }
    c.err = err
    
    // 關閉channel,通知其他協程
    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)
    }
}

這個函式功能就是關閉channel:c.done();
遞迴取消它的所有子節點;最後從父節點刪除自己。
通過關閉channel,將取消訊號傳遞給了它的所有子節點。
goroutine 接收到取消訊號的方式就是 select 語句中的 讀c.done 被選中

4.5 timerCtx 結構體

timerCtx struct:

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

timerCtx嵌入了cancelCtx結構體,所以cancelCtx的方法也可以使用。
timerCtx主要是用於實現WithDeadline和WithTimeout兩個context實現,其繼承了cancelCtx結構體,同時還包含一個timer.Timer定時器和一個deadline終止實現。Timer會在deadline到來時,自動取消context。

cancel()函式

func (c *timerCtx) cancel(removeFromParent bool, err error) {
   c.cancelCtx.cancel(false, err) //由於繼承了cancelCtx,這裡呼叫了cancelCtx的cancel()方法
    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()
}

這個函式繼承了cancelCtx的方法cancel(),然後後面進行自身定時器Stop()的操作,這樣就可以實現取消操作了。

4.6 valueCtx結構體

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

通過key-value來進行值儲存

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

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

4.7 WithCancel方法

WithCancel:

建立一個可取消的context

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

傳入一個父context(通常是一個background作為根節點),返回新建context。
當 WithCancel 函式返回的 CancelFunc 被呼叫或者是父節點的 done channel 被關閉(父節點的 CancelFunc 被呼叫),此 context(子節點) 的 done channel 也會被關閉。

newCancelCtx()方法

func newCancelCtx(parent Context) cancelCtx {
    return cancelCtx{Context: parent}
}

初始化cancelCtx結構體

propagateCancel()方法

這個函式主要作用是當parent context取消時候,進行child context的取消,這有2種模式:

  1. parent取消的時候通知child進行cancel取消
    2.parent取消的時候呼叫child的層層遞迴取消
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
   // 父節點是空的,直接返回
    if parent.Done() == nil {
        return // parent is never canceled
    }
    
    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{})
            }
            // 把這個child放到父節點上
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
      // 如果沒有找到可取消的父 context。新啟動一個協程監控父節點或子節點取消訊號
        go func() {
            select {          
             // 保證父節點被取消的時候子節點會被取消
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
}

parentCancelCtx

這個函式識別三種型別的Context:cancelCtx,timerCtx,valueCtx

func parentCancelCtx(parent Context) (*cancelCtx, bool) {
    for {
        switch c := parent.(type) {
        case *cancelCtx:
            return c, true    // 找到最近支援cancel的parent,由parent進行取消操作的呼叫
        case *timerCtx:
            return &c.cancelCtx, true // 找到最近支援cancel的parent,由parent進行取消操作的呼叫
        case *valueCtx:
            parent = c.Context // 遞迴
        default:
            return nil, false
        }
    }
}

4.8 按時間取消的函式

  • WithTimeout
  • WithDeadline

WithTimeout是直接呼叫WithDeadline函式,傳入deadline是當前時間+timeout的時間,也就是從現在開始經過timeout時間就算超時。也就是說,WithDeadline用的是絕對時間。

WithTimeout():

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) {
    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
        // The current deadline is already sooner than the new one.
        return WithCancel(parent)
    }
    c := &timerCtx{
        cancelCtx: newCancelCtx(parent),
        deadline:  d,
    }
    
// 監聽parent的取消,或者向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 {
        c.timer = time.AfterFunc(dur, func() {
            c.cancel(true, DeadlineExceeded)
        })
    }
    return c, func() { c.cancel(true, Canceled) }
}

WithDeadline 方法在建立 timerCtx 的過程中,判斷了父上下文的截止日期與當前日期,並通過 time.AfterFunc 建立定時器,當時間超過了截止日期後會呼叫 timerCtx.cancel 方法同步取消訊號。

WithCancel、WithDeadline以及WithTimeout都返回了一個Context以及一個CancelFunc函式,返回的Context也就是我們當前基於parent建立了cancelCtx或則timerCtx,通過CancelFunc我們可以取消當前Context,即使timerCtx還未超時。

4.9 WithValue()

https://www.cnblogs.com/qcrao-2018/p/11007503.html

//建立 valueCtx 的函式
func WithValue(parent Context, key, val interface{}) Context {
    if key == nil {
        panic("nil key")
    }
    if !reflectlite.TypeOf(key).Comparable() {
        panic("key is not comparable")
    }
    return &valueCtx{parent, key, val}
}

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

對 key 的要求是可比較,因為之後需要通過 key 取出 context 中的值,可比較是必須的。通過層層傳遞 context,最終形成這樣一棵樹.

和連結串列有點像,只是它的方向相反:Context 指向它的父節點,連結串列則指向下一個節點。通過 WithValue 函式,可以建立層層的 valueCtx,儲存 goroutine 間可以共享的變數。取值的過程,實際上是一個遞迴查詢的過程:

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

它會順著鏈路一直往上找,比較當前節點的 key 是否是要找的 key,如果是,則直接返回 value。否則,一直順著 context 往前,最終找到根節點(一般是 emptyCtx),直接返回一個 nil。所以用 Value 方法的時候要判斷結果是否為 nil。因為查詢方向是往上走的,所以,父節點沒法獲取子節點儲存的值,子節點卻可以獲取父節點的值。WithValue 建立 context 節點的過程實際上就是建立連結串列節點的過程

參考

相關文章