go context剖析之原始碼分析
原始碼面前,了無祕密。本文作為context分析系列的第二篇,會從原始碼的角度來分析context如何實現所承諾的功能及內在特性。本篇主要從以下四個角度闡述: context中的介面、context有哪些型別、context的傳遞實現、context的層級取消觸發實現。
context中的介面
上一篇ofollow,noindex">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 musync.Mutex donechan struct{} children map[canceler]struct{} errerror } 複製程式碼
這裡的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)。這裡有兩個設計很關鍵:
- cancel函式是冪等的,可以被多次呼叫。
- 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,也能從容權衡利弊,做到知其然知其所以然,謝謝。