1. 程式人生 > >go context剖析之原始碼分析

go context剖析之原始碼分析

開篇

原始碼面前,了無祕密。本文作為context分析系列的第二篇,會從原始碼的角度來分析context如何實現所承諾的功能及內在特性。本篇主要從以下四個角度闡述: context中的介面、context有哪些型別、context的傳遞實現、context的層級取消觸發實現。

context中的介面

上一篇go context剖析之使用技巧中可以看到context包本身包含了數個匯出函式,包括WithValue、WithTimeout等,無論是最初構造context還是傳導context,最核心的介面型別都是context.Context,任何一種context也都實現了該介面,包括value context。

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

到底有幾種context?

既然context都需要實現Context,那麼包括不直接可見(非匯出)的結構體,一共有幾種context呢?答案是4種

  • 型別一: emptyCtx,context之源頭

emptyCtx定義如下

type emptyCtx int
複製程式碼

為了減輕gc壓力,emptyCtx其實是一個int,並且以do nothing的方式實現了Context介面,還記得context包裡面有兩個初始化context的函式

func Background() Context
func TODO() Context
複製程式碼

這兩個函式返回的實現型別即為emptyCtx,而在contex包中實現了兩個emptyCtx型別的全域性變數: background、todo,其定義如下

var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)
複製程式碼

上述兩個函式依次對應這兩個全域性變數。到這裡我們可以很確定地說context的根節點就是一個int全域性變數,並且Background()和TODO()是一樣的。所以千萬不要用nil作為context,並且從易於理解的角度出發,未考慮清楚是否傳遞、如何傳遞context時用TODO,其他情況都用Background(),如請求入口初始化context

  • 型別二: cancelCtx,cancel機制之靈魂

cancelCtx的cancel機制是手工取消、超時取消的內部實現,其定義如下

type cancelCtx struct {
	Context

	mu       sync.Mutex
	done     chan struct{}
	children map[canceler]struct{}
	err      error 
}
複製程式碼

這裡的mu是context併發安全的關鍵、done是通知的關鍵、children儲存結構是內部最常用傳導context的方式。

  • 型別三: timerCtx,cancel機制的場景補充

timerCtx內部包含了cancelCtx,然後通過定時器,實現了到時取消的功能,定義如下

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

	deadline time.Time
}
複製程式碼

這裡deadline只做記錄、String()等邊緣功能,timer才是關鍵。

  • 型別四: valueCtx,傳值

valueCtx是四個型別的最後一個,只用來傳值,當然也可以傳遞,所有context都可以傳遞,定義如下

type valueCtx struct {
	Context
	key, val interface{}
}
複製程式碼

由於有的人認為context應該只用來傳值、有的人認為context的cancel機制才是核心,所以對於valueCtx也在下面做了一個單獨的介紹,大家可以通過把握內部實現後按照自己的業務場景做一個取捨(傳值可以用一個全域性結構體、map之類)。

value context的底層是map嗎?

在上面valueCtx的定義中,我們可以看出其實value context底層不是一個map,而是每一個單獨的kv對映都對應一個valueCtx,當傳遞多個值時就要構造多個ctx。同時,這要是value contex不能自低向上傳遞值的原因。

valueCtx的key、val都是介面型別,在呼叫WithValue的時候,內部會首先通過反射確定key是否可比較型別(同map中的key),然後賦值key

在呼叫Value的時候,內部會首先在本context查詢對應的key,如果沒有找到會在parent context中遞迴尋找,這也是value可以自頂向下傳值的原因。

context是如何傳遞的

首先可以明確,任何一種context都具有傳遞性,而傳遞性的內在機制可以理解為: 在呼叫WithCancel、WithTimeout、WithValue時如何處理父子context。從傳遞性的角度來說,幾種With*函式內部都是通過propagateCancel這個函式來實現的,下面以WithCancel函式為例

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

newCancelCtx是cancelCtx賦值父context的過程,而propagateCancel建立父子context之間的聯絡。

propagateCance定義如下

func propagateCancel(parent Context, child canceler) {
	if parent.Done() == nil {
		return // parent is never canceled
	}
	if p, ok := parentCancelCtx(parent); ok {// context包內部可以直接識別、處理的型別
		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 {// context包內部不能直接處理的型別,比如type A struct{context.Context},這種靜默包含的方式
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}
複製程式碼

1.如果parent.Done是nil,則不做任何處理,因為parent context永遠不會取消,比如TODO()、Background()、WithValue等。 2.parentCancelCtx根據parent context的型別,返回bool型ok,ok為真時需要建立parent對應的children,並儲存parent->child對映關係(cancelCtx、timerCtx這兩種型別會建立,valueCtx型別會一直向上尋找,而迴圈往上找是因為cancel是必須的,然後找一種最合理的。),這裡children的key是canceler介面,並不能處理所有的外部型別,所以會有else,示例見上述程式碼註釋處。對於其他外部型別,不建立直接的傳遞關係。 parentCancelCtx定義如下

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
		}
	}
}
複製程式碼

context是如何觸發取消的

上文在闡述傳遞性時的實現時,也包含了一部分取消機制的程式碼,這裡不會再列出原始碼,但是會依據上述原始碼進行說明。對於幾種context,傳遞過程大同小異,但是取消機制有所不同,針對每種型別,我會一一解釋。不同型別的context可以在一條鏈路進行取消,但是每一個context的取消只會被一種條件觸發,所以下面會單獨介紹下每一種context的取消機制(組合取消的場景,按照先到先得的原則,無論那種條件觸發的,都會傳遞呼叫cancel)。這裡有兩個設計很關鍵:

  1. cancel函式是冪等的,可以被多次呼叫。
  2. context中包含done channel可以用來確認是否取消、通知取消。
  • cancelCtx型別

cancelCtx會主動進行取消,在自頂向下取消的過程中,會遍歷children context,然後依次主動取消。 cancel函式定義如下

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)
	}
}
複製程式碼
  • timerCtx型別

WithTimeout是通過WithDeadline來實現的,均對應timerCtx型別。通過parentCancelCtx函式的定義我們知道,timerCtx也會記錄父子context關係。但是timerCtx是通過timer定時器觸發cancel呼叫的,部分實現如下

	if c.err == nil {
	    c.timer = time.AfterFunc(dur, func() {
	        c.cancel(true, DeadlineExceeded)
            })
	}
複製程式碼
  • 靜默包含context

這裡暫時只想到了靜默包含即type A struct{context.Context}的情況。通過parentCancelCtx和propagateCancel我們知道這種context不會建立父子context的直接聯絡,但是會通過單獨的goroutine去檢測done channel,來確定是否需要觸發鏈路上的cancel函式,實現見propagateCancel的else部分。

結尾

context的實現並不複雜,但是在實際開發中確能帶來不小的便利性。篇一力求大家能夠按場景對號入座熟練地使用context,篇二希望大家能夠從原始碼層面瞭解到context的實現,在一些極端場景下,如靜默包含context,也能從容權衡利弊,做到知其然知其所以然,謝謝。