Go Context 原理詳解

木的樹發表於2022-05-01

實現一個小目標

很開心的一件事,學習了一個月的後端拿到一個13k的offer,今年年底目標拿到一個30k的go方向offer。
0
好了迴歸正文,這篇文章是回答交流時一個老哥的問題,跟go的context相關內容,上一篇(https://www.cnblogs.com/dojo-lzz/p/16183006.html)講了一些基礎知識,這一篇繼續在併發處理上進行研究。主要是Go Context的使用、原理。因為時間和精力有限,所以文章中大量引用相關資料中的內容以及圖片,再此致敬。
 

Go Context

React中Context主要用來跨元件傳遞一些資料,Go中Context其中一個作用也跟傳遞資料有關,不過是在goroutine中相互傳遞資料;Context的另一個作用在於可以便捷關閉被建立出來的goroutine。
在實際中當伺服器端收到一個請求時,很可能需要傳送幾個請求去請求其他服務的資料,由於Go 語法上的同步阻塞寫法,我們一般會建立幾個goroutine併發去做一些事情;那麼這時候很可能幾個goroutine之間需要共享資料,還有當request被取消時,建立的幾個goroutine也應該被取消掉。那麼這就是Go Context的用武之地。
關於協程洩露:
一般main函式是主協程,主協程執行完畢後子協程也會被銷燬;但是對於服務來說,主協程不會執行完畢就退出。
所以如果每個請求都自己建立協程,而協程有沒有受到完畢資訊結束資訊,可能處於阻塞狀態,這種情況下才會產生協程洩露
 
context包中核心是Context介面:
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  • Deadline 方法返回當前Context被取消的時間,也就是完成工作的截止時間(deadline);
  • Done方法需要返回一個channel,這個Channel會在當前工作完成或者上下文被取消之後關閉,可以在子goroutine中利用select進行監控,來回收子goroutine;多次呼叫Done方法會返回同一個Channel;
// Done is provided for use in select statements:
//  // Stream generates values with DoSomething and sends them to out
//  // until DoSomething returns an error or ctx.Done is closed.
//  func Stream(ctx context.Context, out chan<- Value) error {
//      for {
//          v, err := DoSomething(ctx)
//          if err != nil {
//              return err
//          }
//          select {
//          case <-ctx.Done():
//              return ctx.Err()
//          case out <- v:
//          }
//      }
//  }
// See https://blog.golang.org/pipelines for more examples of how to use
// a Done channel for cancellation.
  • Err方法會返回當前Context結束的原因,它只會在Done返回的Channel被關閉時才會返回空值:
    • 如果當前Context被取消就會返回Canceled錯誤;
    • 如果當前Context超時就會返回DeadlineExceeded錯誤;
  • Value 方法會從Context中返回鍵對應的值,對於同一個上下文來說,多次呼叫Value並傳入相同的Key會返回相同的結果,該方法僅用於傳遞跨API和程式間跟請求域的資料。
//     // Package user defines a User type that's stored in Contexts.
//     package user
//     import "context"
//     // User is the type of value stored in the Contexts.
//     type User struct {...}
//
//     // key is an unexported type for keys defined in this package.
//     // This prevents collisions with keys defined in other packages.
//     type key int
//     // userKey is the key for user.User values in Contexts. It is
//     // unexported; clients use user.NewContext and user.FromContext
//     // instead of using this key directly.
//     var userKey key

//     // NewContext returns a new Context that carries value u.
//     func NewContext(ctx context.Context, u *User) context.Context {
//         return context.WithValue(ctx, userKey, u)
//     }
//     // FromContext returns the User value stored in ctx, if any.
//     func FromContext(ctx context.Context) (*User, bool) {
//         u, ok := ctx.Value(userKey).(*User)
//         return u, ok
//     }

 

ctx.Value(userKey).(*User)這裡是Go語言中的型別斷言(http://c.biancheng.net/view/4281.html
value, ok := x.(T)
x 表示一個介面的型別,T 表示一個具體的型別(也可為介面型別)
該斷言表示式會返回 x 的值(也就是 value)和一個布林值(也就是 ok),可根據該布林值判斷 x 是否為 T 型別:

如果 T 是具體某個型別,型別斷言會檢查 x 的動態型別是否等於具體型別 T。如果檢查成功,型別斷言返回的結果是 x 的動態值,其型別是 T。
如果 T 是介面型別,型別斷言會檢查 x 的動態型別是否滿足 T。如果檢查成功,x 的動態值不會被提取,返回值是一個型別為 T 的介面值。
無論 T 是什麼型別,如果 x 是 nil 介面值,型別斷言都會失敗。

 

在context包中Context一個介面有四個具體實現和六個函式:
0

emptyCtx

emptyCtx本質是一個整型型別,他對Context介面的實現,非常簡單,其實是什麼也沒做,都是一堆空方法:
// An emptyCtx is never canceled, has no values, and has no deadline. It is not
// struct{}, since vars of this type must have distinct addresses.
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 any) any {
    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)
)

 

這裡的String方法挺有意思,因為下面中可以看到background和todo都是一個emptyContext所以,這裡直接case進行對比background和todo;
// Background returns a non-nil, empty Context. It is never canceled, has no
// values, and has no deadline. It is typically used by the main function,
// initialization, and tests, and as the top-level Context for incoming
// requests.
func Background() Context {
    return background
}

// TODO returns a non-nil, empty Context. Code should use context.TODO when
// it's unclear which Context to use or it is not yet available (because the
// surrounding function has not yet been extended to accept a Context
// parameter).
func TODO() Context {
    return todo
}

 

 

cancelCtx

通過WithCancel來建立的就是cancelCtx,WithCancel返回一個ctx和cancel方法,通過呼叫cancel方法,可以將Context取消,來控制協程,具體看下面例子:
在這個例子中,通過defer呼叫cancel,在FixLeakingByContext函式結束時去掉context,在CancelByContext中配合select和context的done方式來使用,可以避免協程資源沒有被回收引起的記憶體洩露。
func FixLeakingByContex() {
    //建立上下文用於管理子協程
    ctx, cancel := context.WithCancel(context.Background())
    
    //結束前清理未結束協程
    defer cancel()
    
    ch := make(chan int)
    go CancelByContext(ctx, ch)
    go CancelByContext(ctx, ch)
    go CancelByContext(ctx, ch)
    
    // 隨機觸發某個子協程退出
    ch <- 1
}

func CancelByContext(ctx context.Context, ch chan (int)) int {
    select {
    case <-ctx.Done():
        //fmt.Println("cancel by ctx.")
        return 0
    case n := <-ch :
        return n
    }
}

 

看下WithCancel的原始碼:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    if parent == nil {
        panic("cannot create context from nil parent")
    }
         // WithCancel通過一個父級Context來建立出一個cancelCtx
    c := newCancelCtx(parent)
         // 呼叫propagateCancel根據父級context的狀態來關聯cancelCtx的cancel行為
    propagateCancel(parent, &c)
         // 返回c和一個方法,方法中呼叫c.cancel並傳遞Canceled變數
    return &c, func() { c.cancel(true, Canceled) }
}

func newCancelCtx(parent Context) cancelCtx {
    return cancelCtx{Context: parent}
}
var Canceled = errors.New("context canceled")

 

WithCancel通過一個父級Context來建立出一個cancelCtx,然後呼叫propagateCancel根據父級context的狀態來關聯cancelCtx的cancel行為(感覺這裡不應該叫propagate,冒泡一般理解是自下向上,這個函式明顯是自下向上,應該叫cascade更為合理一些)。隨後返回c和一個方法,方法中呼叫c.cancel並傳遞Canceled變數(其實是一個error例項);
cancelCtx是WidthDeadline和WidthTimeout的基石,所以cancelCtx的實現相對複雜,我們重點講解。
 
newCancelCtx方法可以看到是建立了一個cancelCtx例項
func newCancelCtx(parent Context) cancelCtx {
    return cancelCtx{Context: parent}
}

 

我們也看下cancelCtx的定義:
// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct {
    Context // 內嵌結構體

    mu       sync.Mutex            // protects following fields
    done     atomic.Value          // of 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
}

 

cancelCtx有一個內嵌的Context型別,實際儲存的都是父級上下文物件,還有四個獨立的欄位:
  • mu:一個互斥量,用來加鎖保證某些操作的執行緒安全性
  • done:atomic.Value一個可以對任意型別進行原子型操作的結構;提供Load和Store方法;看Go原始碼這裡存的是一個struct{}型別的channel
  • children:一個key為canceler值為struct{}的map型別;
  • err:存放error的欄位
這裡的cancelder是一個介面,代表可以直接被cancel的Context型別,基本指的是 *cancelCtx和 *timerCtx兩種context,也被他倆實現
// A canceler is a context type that can be canceled directly. The
// implementations are *cancelCtx and *timerCtx.
type canceler interface {
    cancel(removeFromParent bool, err error)
    Done() <-chan struct{}
}

 

下面看下propagateCancel,據父級context的狀態來關聯cancelCtx的cancel行為
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
    // 如果父元素的Done方法返回為空,也就是說父context是emptyCtx
    // 直接返回,因為父上下文不會做任何處理
    done := parent.Done()
    if done == nil {
        return // parent is never canceled
    }
    // 如果父上下文不是emptyCtx型別,使用select來判斷一下父上下文的done channel是不是已經被關閉掉了
    // 關閉則呼叫child的cancel方法
    // select其實會阻塞,但這裡給了一個default方法,所以如果父上下文的done channel沒有被關閉則繼續之心後續程式碼
    // 這裡相當於利用了select的阻塞性來做if-else判斷
    select {
    case <-done:
        // parent is already canceled
        child.cancel(false, parent.Err())
        return
    default:
    }

    // parentCancelCtx目的在於尋找父上下文中最底層的cancelCtx,因為像timerCtx等會內嵌cancelCtx
    if p, ok := parentCancelCtx(parent); ok {
        // 如果找的到,就把最內層的cancelCtx跟child的設定好關聯關係
        // 這裡要考慮到多執行緒環境,所以是加鎖處理
        p.mu.Lock()
        if p.err != nil {
            // 如果祖先cancelCtx已經被取消了,那麼也呼叫child的cancel方法
            // parent has already been canceled
            child.cancel(false, p.err)
        } else {
             // 這裡設定內層cancelCtx與child的父子層級關係
            if p.children == nil {
                p.children = make(map[canceler]struct{})
            }
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
        // 這裡代表沒有找到祖先cancelCtx,單啟了一個協程來進行監聽(因為select是阻塞的),如果父上下文的done 關閉了,則子上下文取消
        
        // goroutines在別的地方程式碼中沒有使用,不知道為什麼要做增加操作,看原始碼英文解釋也是為了測試使用
                // 單獨的協程會在阻塞完畢後被GC回收,不會有洩露風險                  
        atomic.AddInt32(&goroutines, +1)
        go func() {
            select {
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
}

 

 
裡面呼叫了一個parentCancelCtx函式,這個函式比較晦澀,市面上資料也還沒有人去仔細研究,這裡我來講解一下;
這個函式中最重要的就是12行,通過cancelCtxKey獲取最近的內嵌cancelCtx;然後讓在propagateCancel中設定內嵌cancelCtx與child的關聯關係;
同時這個函式也考慮了幾種情況,如果parent的done已經是closedchan或者是nil那麼沒必要去拿內層的cancelCtx來建立層級關係,直接用parent本身與child做好關聯cancel即可。這是9-11行程式碼乾的事。
16行-19行,看原始碼解釋是如果這個內嵌cancelCtx可能加了一些自定義方法,比如複寫了Done或者cancel,那麼它就不是這裡的timerCtx、cancelCtx或者valueCtx,這種情況下使用者自己負責處理;放到propagateCancel這個函式中就是把parent和child直接關聯起來,不建立層級關係。及時子child自己cancel也不去跟parent的children有什麼關聯。
// parentCancelCtx returns the underlying *cancelCtx for parent.
// It does this by looking up parent.Value(&cancelCtxKey) to find
// the innermost enclosing *cancelCtx and then checking whether
// parent.Done() matches that *cancelCtx. (If not, the *cancelCtx
// has been wrapped in a custom implementation providing a
// different done channel, in which case we should not bypass it.)
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
    done := parent.Done()
    if done == closedchan || done == nil {
        return nil, false
    }
    p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
    if !ok {
        return nil, false
    }
    pdone, _ := p.done.Load().(chan struct{})
    if pdone != done {
        return nil, false
    }
    return p, true
}

 

那麼這裡就有了一個問題,propagateCancel函式中一定建立parent和child的children關係麼?我理解是不用的,因為這個else部分程式碼我理解完全可以實現父級上下文結束後,child也進行取消;我猜這裡儘量建立children的map關係,是如果不這麼做就要起一個goroutine來處理,相當於一個監護執行緒,goroutine資源的消耗以及排程成本,比單純的children層級關係更大,所以這裡盡力使用map結構來建立層級關係。這也可以看到作者在寫程式碼時候還是很花心思去考量各種情況的。
    } else {
        // 這裡代表沒有找到祖先cancelCtx,單啟了一個協程來進行監聽(因為select是阻塞的),如果父上下文的done 關閉了,則子上下文取消
        
        // goroutines在別的地方程式碼中沒有使用,不知道為什麼要做增加操作,看原始碼英文解釋也是為了測試使用
                // 單獨的協程會在阻塞完畢後被GC回收,不會有洩露風險                  
        atomic.AddInt32(&goroutines, +1)
        go func() {
            select {
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
接下來看下cancelCtx中Value、Done、Err以及私有方法cancel的實現;
 
Value方法
原始碼如下
func (c *cancelCtx) Value(key any) any {
    if key == &cancelCtxKey {
        return c
    }
    return value(c.Context, key)
}
首先要介紹下cancelCtxKey,這是一個context包中的私有變數,當對cancelCtx呼叫Value方法並用這個key作為引數時,返回cancelCtx本身;
如果沒有找到則是呼叫的context包中的私有方法value,來在父級上下文中key對應的值;
 
這個方法首先進行型別斷言,判斷Context是否是valueCtx、cancelCtx、timerCtx以及emptyCtx等;根據不同的型別做不同處理,比如cancelCtx和timerCtx先進行cancelCtxKey判斷,emptyCtx直接返回nil,valueCtx則判斷是否是自己例項化時候傳入的key,否則就去自己的內層context也就是parent層級上冒泡獲取對應的值。
func value(c Context, key any) any {
    for {
        switch ctx := c.(type) {
        case *valueCtx:
            if key == ctx.key {
                return ctx.val
            }
            c = ctx.Context
        case *cancelCtx:
            if key == &cancelCtxKey {
                return c
            }
            c = ctx.Context
        case *timerCtx:
            if key == &cancelCtxKey {
                return &ctx.cancelCtx
            }
            c = ctx.Context
        case *emptyCtx:
            return nil
        default:
            return c.Value(key)
        }
    }
}

 

 
Done方法
func (c *cancelCtx) Done() <-chan struct{} {
        // 返回atomic.Value中儲存的值
    d := c.done.Load()
    if d != nil {
                 // atomic.Value型別的Load方法返回的是ifaceWords型別,所以這裡是利用了型別斷言
                 // 把ifaceWords型別轉換為 struct型別的chan
        return d.(chan struct{})
    }
         // 這裡是併發場景要考慮的問題,因為會存在多個執行緒併發進行的過程,所以不一定哪個goroutine就對c.done進行了修改
         // 所以這裡不能直接像單執行緒一樣,if d!=nil else。。。;首先得搶鎖。
    c.mu.Lock()
    defer c.mu.Unlock()
    d = c.done.Load()
         // 上面搶鎖的過程可能搶到了,也可能沒搶到,所以到這裡是搶到了鎖,但是c.done未必還是nil;
         // 所以這裡要再次做判斷
    if d == nil {
        d = make(chan struct{})
        c.done.Store(d)
    }
    return d.(chan struct{})
}
 
看到上面鎖的過程,發現併發情況的處理要比js這種單執行緒考慮的多得多。併發對一個變數的處理不能簡單的if-else;要結合鎖、CAS、原子操作一起考慮(對於atomic.Value中的ifaceWords的部分可以看這篇文章:https://www.cnblogs.com/dojo-lzz/p/16183006.html中原子操作部分)。
 
Err方法
func (c *cancelCtx) Err() error {
    c.mu.Lock()
    err := c.err
    c.mu.Unlock()
    return err
}
這個方法比較簡單只是獲取了cancelCtx的err屬性,這個屬性在cancel中會會被設定。
 
cancel方法
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    if err == nil {
        panic("context: internal error: missing cancel error")
    }
         // 因為後面要對c.err和c.done進行更新,所以這裡要搶鎖
    c.mu.Lock()
    if c.err != nil {
                 // if這部分放到鎖的外部是否可以?看起來是可以的,但是如果放到外面,if判斷不通過此時c.err為nil
                 // 接著進行搶鎖,那麼在搶到鎖之後仍然要對c.err判斷是否還是nil,才能進行更新
                 // 因為在搶鎖過程中,可能c.err已經被某個協程修改了
                 // 所以把這部分放到鎖之後是合理的。
        c.mu.Unlock()
        return // already canceled
    }
    c.err = err // 賦值
    d, _ := c.done.Load().(chan struct{})
 // 讀取done的值
    if d == nil { 
                 // 如果done為nil,就把一個內部的closedchan存入c.done中;
                 // closedchan是一個channel型別,在context包的init函式中就會把它close掉
        c.done.Store(closedchan)
    } else {
        close(d)
    }         
         // 遍歷c的children呼叫他們的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)
    }
}
程式碼最後呼叫removeChild方法,這部分為什麼沒在c.mu鎖中,我猜是因為這個函式的程式碼自己會進行鎖的處理。
 
// removeChild removes a context from its parent.
func removeChild(parent Context, child canceler) {
    p, ok := parentCancelCtx(parent)
    if !ok {
        return
    }
    p.mu.Lock()
    if p.children != nil {
        delete(p.children, child)
    }
    p.mu.Unlock()
}
可以看到程式碼中的鎖部分,是在第7行開始的,那麼為什麼parentCancelCtx沒有被包含在鎖中,這裡猜測下,因為parentCancelCtx的主要目的是為了獲取父級上下文內層的cancelCtx,而這個值是在例項化時候就已經確定的,這裡只是讀取所以可以不用放在互斥鎖的臨界區程式碼中,避免效能浪費。
接下來就是p.mu來搶鎖,完成對層級結構的接觸。
 

timerCtx

WithTimeout和WithDeadline建立的都是timerCtx,timerCtx內部內嵌了cancelCtx;
type timerCtx struct {
    cancelCtx
    timer *time.Timer // Under cancelCtx.mu.

    deadline time.Time
}
因為內嵌了cancelCtx,而cancelCtx實現了Done、Value、Err以及cancel(私有)方法,所以timerCtx上也可以直接呼叫這幾個方法(http://c.biancheng.net/view/72.html);cancelCtx並未實現Deadline方法,但是emptyCtx實現了,如果他的父級上下文是emptyCtx那麼cancelCtx也可以呼叫Deadline方法。
 
看完cancelCtx的方法之後,對比起來timerCtx的方法都比較簡單,不做過多解釋
func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
    return c.deadline, true
}

func (c *timerCtx) String() string {
    return contextName(c.cancelCtx.Context) + ".WithDeadline(" +
        c.deadline.String() + " [" +
        time.Until(c.deadline).String() + "])"
}

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 {
        c.timer.Stop()
        c.timer = nil
    }
    c.mu.Unlock()
}
可以看到cancel方法中先呼叫了內嵌的cancelCtx的cancel方法;然後利用cancelCtx的互斥鎖搶鎖來對c.timer進行操作修改;cancel方法第13-16行需要注意,因為withDeadline在建立時把parent和timerCtx建立了層級關係,所以這裡根據條件進行移除操作。
 
下面來看下withDeadline函式:
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
    if parent == nil {
        panic("cannot create context from nil parent")
    }
         // 如果parent的deadline小於當前時間,直接建立cancelCtx,裡面會呼叫propagateCancel方法
         // 來根據父上下文狀態進行處理
    if cur, ok := parent.Deadline(); ok && cur.Before(d) {
        // The current deadline is already sooner than the new one.
        return WithCancel(parent)
    }
         // 建立timerCtx,這裡可以看到cancelCtx是私有變數,而cancelCtx中的Context欄位是公有變數
    c := &timerCtx{
        cancelCtx: newCancelCtx(parent),
        deadline:  d,
    }
         // 設定層級取消關聯
    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()
         // 如果沒有超時並且沒有被呼叫過cancel,那麼設定timer,超時則呼叫cancel方法;         
    if c.err == nil {
        c.timer = time.AfterFunc(dur, func() {
            c.cancel(true, DeadlineExceeded)
        })
    }
    return c, func() { c.cancel(true, Canceled) }
}
瞭解上面內容之後,WithTimeout就很簡單了,只是呼叫了WidthDeadline方法
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

 

 

valeCtx

這個結構體相對簡單,有一個Context公共變數,一個任意型別的key和任意型別的any:
type valueCtx struct {
    Context
    key, val any
}

 

withValue方法也比較簡單,這裡就不做過多介紹
func WithValue(parent Context, key, val any) Context {
    if parent == nil {
        panic("cannot create context from nil parent")
    }
    if key == nil {
        panic("nil key")
    }
    if !reflectlite.TypeOf(key).Comparable() {
        panic("key is not comparable")
    }
    return &valueCtx{parent, key, val}
}

 

還有一個Value方法:
如果key與WithValue呼叫時相同,則返回對應的val,否則進入value方法,在內嵌的Context中查詢key對應的值,這個方法上面介紹過,根據Context型別先做一些型別判斷,來判斷一些關鍵的key如cancelCtxKey,不然繼續在內嵌Context中查詢。
func (c *valueCtx) Value(key any) any {
    if c.key == key {
        return c.val
    }
    return value(c.Context, key)
}

 

 

參考資料

本文大量引用了相關參考資料的圖片和語言。版權問題請與我聯絡,侵刪。

相關文章