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)。這裡有兩個設計很關鍵:
- 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,也能從容權衡利弊,做到知其然知其所以然,謝謝。