深度解密Go語言之context

Go 語言的 context 包短小精悍,很是適合新手學習。不管是它的源碼仍是實際使用,都值得投入時間去學習。html

這篇文章依然想嘗試全面、深刻地去研究。文章相比往期而言,總體不長,但願你看完能夠有所收穫!git

什麼是 context

Go 1.7 標準庫引入 context,中文譯做「上下文」,準確說它是 goroutine 的上下文,包含 goroutine 的運行狀態、環境、現場等信息。github

context 主要用來在 goroutine 之間傳遞上下文信息,包括:取消信號、超時時間、截止時間、k-v 等。golang

隨着 context 包的引入,標準庫中不少接口所以加上了 context 參數,例如 database/sql 包。context 幾乎成爲了併發控制和超時控制的標準作法。web

context.Context 類型的值能夠協調多個 groutine 中的代碼執行「取消」操做,而且能夠存儲鍵值對。最重要的是它是併發安全的。

與它協做的 API 均可以由外部控制執行「取消」操做,例如:取消一個 HTTP 請求的執行。sql

沒看懂?不要緊,先日後看。shell

爲何有 context

Go 經常使用來寫後臺服務,一般只須要幾行代碼,就能夠搭建一個 http server。數據庫

在 Go 的 server 裏,一般每來一個請求都會啓動若干個 goroutine 同時工做:有些去數據庫拿數據,有些調用下游接口獲取相關數據……segmentfault

request

這些 goroutine 須要共享這個請求的基本數據,例如登錄的 token,處理請求的最大超時時間(若是超過此值再返回數據,請求方由於超時接收不到)等等。當請求被取消或是處理時間太長,這有多是使用者關閉了瀏覽器或是已經超過了請求方規定的超時時間,請求方直接放棄了此次請求結果。這時,全部正在爲這個請求工做的 goroutine 須要快速退出,由於它們的「工做成果」再也不被須要了。在相關聯的 goroutine 都退出後,系統就能夠回收相關的資源。後端

再多說一點,Go 語言中的 server 其實是一個「協程模型」,也就是說一個協程處理一個請求。例如在業務的高峯期,某個下游服務的響應變慢,而當前系統的請求又沒有超時控制,或者超時時間設置地過大,那麼等待下游服務返回數據的協程就會愈來愈多。而咱們知道,協程是要消耗系統資源的,後果就是協程數激增,內存佔用飆漲,甚至致使服務不可用。更嚴重的會致使雪崩效應,整個服務對外表現爲不可用,這確定是 P0 級別的事故。這時,確定有人要背鍋了。

其實前面描述的 P0 級別事故,經過設置「容許下游最長處理時間」就能夠避免。例如,給下游設置的 timeout 是 50 ms,若是超過這個值尚未接收到返回數據,就直接向客戶端返回一個默認值或者錯誤。例如,返回商品的一個默認庫存數量。注意,這裏設置的超時時間和建立一個 http client 設置的讀寫超時時間不同,這裏不詳細展開。能夠去看看參考資料【Go 在今日頭條的實踐】一文,有很精彩的論述。

context 包就是爲了解決上面所說的這些問題而開發的:在 一組 goroutine 之間傳遞共享的值、取消信號、deadline……

request with context

用簡練一些的話來講,在Go 裏,咱們不能直接殺死協程,協程的關閉通常會用 channel+select 方式來控制。可是在某些場景下,例如處理一個請求衍生了不少協程,這些協程之間是相互關聯的:須要共享一些全局變量、有共同的 deadline 等,並且能夠同時被關閉。再用 channel+select 就會比較麻煩,這時就能夠經過 context 來實現。

一句話:context 用來解決 goroutine 之間退出通知元數據傳遞的功能。

context 底層實現原理

咱們分析的 Go 版本依然是 1.9.2

總體概覽

context 包的代碼並不長,context.go 文件總共不到 500 行,其中還有不少大段的註釋,代碼可能也就 200 行左右的樣子,是一個很是值得研究的代碼庫。

先給你們看一張總體的圖:

structure

類型 名稱 做用
Context 接口 定義了 Context 接口的四個方法
emptyCtx 結構體 實現了 Context 接口,它實際上是個空的 context
CancelFunc 函數 取消函數
canceler 接口 context 取消接口,定義了兩個方法
cancelCtx 結構體 能夠被取消
timerCtx 結構體 超時會被取消
valueCtx 結構體 能夠存儲 k-v 對
Background 函數 返回一個空的 context,常做爲根 context
TODO 函數 返回一個空的 context,經常使用於重構時期,沒有合適的 context 可用
WithCancel 函數 基於父 context,生成一個能夠取消的 context
newCancelCtx 函數 建立一個可取消的 context
propagateCancel 函數 向下傳遞 context 節點間的取消關係
parentCancelCtx 函數 找到第一個可取消的父節點
removeChild 函數 去掉父節點的孩子節點
init 函數 包初始化
WithDeadline 函數 建立一個有 deadline 的 context
WithTimeout 函數 建立一個有 timeout 的 context
WithValue 函數 建立一個存儲 k-v 對的 context

上面這張表展現了 context 的全部函數、接口、結構體,能夠縱覽全局,能夠在讀完文章後,再回頭細看。

總體類圖以下:

classes

接口

Context

如今能夠直接看源碼:

type Context interface {
    // 當 context 被取消或者到了 deadline,返回一個被關閉的 channel
    Done() <-chan struct{}

    // 在 channel Done 關閉後,返回 context 取消緣由
    Err() error

    // 返回 context 是否會被取消以及自動取消時間(即 deadline)
    Deadline() (deadline time.Time, ok bool)

    // 獲取 key 對應的 value
    Value(key interface{}) interface{}
}

Context 是一個接口,定義了 4 個方法,它們都是冪等的。也就是說連續屢次調用同一個方法,獲得的結果都是相同的。

Done() 返回一個 channel,能夠表示 context 被取消的信號:當這個 channel 被關閉時,說明 context 被取消了。注意,這是一個只讀的channel。 咱們又知道,讀一個關閉的 channel 會讀出相應類型的零值。而且源碼裏沒有地方會向這個 channel 裏面塞入值。換句話說,這是一個 receive-only 的 channel。所以在子協程裏讀這個 channel,除非被關閉,不然讀不出來任何東西。也正是利用了這一點,子協程從 channel 裏讀出了值(零值)後,就能夠作一些收尾工做,儘快退出。

Err() 返回一個錯誤,表示 channel 被關閉的緣由。例如是被取消,仍是超時。

Deadline() 返回 context 的截止時間,經過此時間,函數就能夠決定是否進行接下來的操做,若是時間過短,就能夠不往下作了,不然浪費系統資源。固然,也能夠用這個 deadline 來設置一個 I/O 操做的超時時間。

Value() 獲取以前設置的 key 對應的 value。

canceler

再來看另一個接口:

type canceler interface {
    cancel(removeFromParent bool, err error)
    Done() <-chan struct{}
}

實現了上面定義的兩個方法的 Context,就代表該 Context 是可取消的。源碼中有兩個類型實現了 canceler 接口:*cancelCtx*timerCtx。注意是加了 * 號的,是這兩個結構體的指針實現了 canceler 接口。

Context 接口設計成這個樣子的緣由:

  • 「取消」操做應該是建議性,而非強制性

caller 不該該去關心、干涉 callee 的狀況,決定如何以及什麼時候 return 是 callee 的責任。caller 只需發送「取消」信息,callee 根據收到的信息來作進一步的決策,所以接口並無定義 cancel 方法。

  • 「取消」操做應該可傳遞

「取消」某個函數時,和它相關聯的其餘函數也應該「取消」。所以,Done() 方法返回一個只讀的 channel,全部相關函數監聽此 channel。一旦 channel 關閉,經過 channel 的「廣播機制」,全部監聽者都能收到。

結構體

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
}

看這段源碼,很是 happy。由於每一個函數都實現的異常簡單,要麼是直接返回,要麼是返回 nil。

因此,這其實是一個空的 context,永遠不會被 cancel,沒有存儲值,也沒有 deadline。

它被包裝成:

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

經過下面兩個導出的函數(首字母大寫)對外公開:

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

background 一般用在 main 函數中,做爲全部 context 的根節點。

todo 一般用在並不知道傳遞什麼 context的情形。例如,調用一個須要傳遞 context 參數的函數,你手頭並無其餘 context 能夠傳遞,這時就能夠傳遞 todo。這經常發生在重構進行中,給一些函數添加了一個 Context 參數,但不知道要傳什麼,就用 todo 「佔個位子」,最終要換成其餘 context。

cancelCtx

再來看一個重要的 context:

type cancelCtx struct {
    Context

    // 保護以後的字段
    mu       sync.Mutex
    done     chan struct{}
    children map[canceler]struct{}
    err      error
}

這是一個能夠取消的 Context,實現了 canceler 接口。它直接將接口 Context 做爲它的一個匿名字段,這樣,它就能夠被當作一個 Context。

先來看 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
}

c.done 是「懶漢式」建立,只有調用了 Done() 方法的時候纔會被建立。再次說明,函數返回的是一個只讀的 channel,並且沒有地方向這個 channel 裏面寫數據。因此,直接調用讀這個 channel,協程會被 block 住。通常經過搭配 select 來使用。一旦關閉,就會當即讀出零值。

Err()String() 方法比較簡單,很少說。推薦看源碼,很是簡單。

接下來,咱們重點關注 cancel() 方法的實現:

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    // 必需要傳 err
    if err == nil {
        panic("context: internal error: missing cancel error")
    }
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // 已經被其餘協程取消
    }
    // 給 err 字段賦值
    c.err = err
    // 關閉 channel,通知其餘協程
    if c.done == nil {
        c.done = closedchan
    } else {
        close(c.done)
    }
    
    // 遍歷它的全部子節點
    for child := range c.children {
        // 遞歸地取消全部子節點
        child.cancel(false, err)
    }
    // 將子節點置空
    c.children = nil
    c.mu.Unlock()

    if removeFromParent {
        // 從父節點中移除本身 
        removeChild(c.Context, c)
    }
}

整體來看,cancel() 方法的功能就是關閉 channel:c.done;遞歸地取消它的全部子節點;從父節點從刪除本身。達到的效果是經過關閉 channel,將取消信號傳遞給了它的全部子節點。goroutine 接收到取消信號的方式就是 select 語句中的讀 c.done 被選中。

咱們再來看建立一個可取消的 Context 的方法:

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

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

這是一個暴露給用戶的方法,傳入一個父 Context(這一般是一個 background,做爲根節點),返回新建的 context,新 context 的 done channel 是新建的(前文講過)。

當 WithCancel 函數返回的 CancelFunc 被調用或者是父節點的 done channel 被關閉(父節點的 CancelFunc 被調用),此 context(子節點) 的 done channel 也會被關閉。

注意傳給 WithCancel 方法的參數,前者是 true,也就是說取消的時候,須要將本身從父節點裏刪除。第二個參數則是一個固定的取消錯誤類型:

var Canceled = errors.New("context canceled")

還注意到一點,調用子節點 cancel 方法的時候,傳入的第一個參數 removeFromParent 是 false。

兩個問題須要回答:1. 何時會傳 true?2. 爲何有時傳 true,有時傳 false?

removeFromParent 爲 true 時,會將當前節點的 context 從父節點 context 中刪除:

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()
}

最關鍵的一行:

delete(p.children, child)

何時會傳 true 呢?答案是調用 WithCancel() 方法的時候,也就是新建立一個可取消的 context 節點時,返回的 cancelFunc 函數會傳入 true。這樣作的結果是:當調用返回的 cancelFunc 時,會將這個 context 從它的父節點裏「除名」,由於父節點可能有不少子節點,你本身取消了,因此我要和你斷絕關係,對其餘人沒影響。

在取消函數內部,我知道,我全部的子節點都會由於個人一:c.children = nil 而化爲灰燼。我天然就沒有必要再多作這一步,最後我全部的子節點都會和我斷絕關係,不必一個個作。另外,若是遍歷子節點的時候,調用 child.cancel 函數傳了 true,還會形成同時遍歷和刪除一個 map 的境地,會有問題的。

context cancel

如上左圖,表明一棵 context 樹。當調用左圖中標紅 context 的 cancel 方法後,該 context 從它的父 context 中去除掉了:實線箭頭變成了虛線。且虛線圈框出來的 context 都被取消了,圈內的 context 間的父子關係都蕩然無存了。

重點看 propagateCancel()

func propagateCancel(parent Context, child canceler) {
    // 父節點是個空節點
    if parent.Done() == nil {
        return // parent is never canceled
    }
    // 找到能夠取消的父 context
    if p, ok := parentCancelCtx(parent); ok {
        p.mu.Lock()
        if p.err != nil {
            // 父節點已經被取消了,本節點(子節點)也要取消
            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。新啓動一個協程監控父節點或子節點取消信號
        go func() {
            select {
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
}

這個方法的做用就是向上尋找能夠「掛靠」的「可取消」的 context,而且「掛靠」上去。這樣,調用上層 cancel 方法的時候,就能夠層層傳遞,將那些掛靠的子 context 同時「取消」。

這裏着重解釋下爲何會有 else 描述的狀況發生。else 是指當前節點 context 沒有向上找到能夠取消的父節點,那麼就要再啓動一個協程監控父節點或者子節點的取消動做。

這裏就有疑問了,既然沒找到能夠取消的父節點,那 case <-parent.Done() 這個 case 就永遠不會發生,因此能夠忽略這個 case;而 case <-child.Done() 這個 case 又啥事不幹。那這個 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 類型:cancelCtx,timerCtx,*valueCtx。如果把 Context 內嵌到一個類型裏,就識別不出來了。

因爲 context 包的代碼並很少,因此我直接把它 copy 出來了,而後在 else 語句里加上了幾條打印語句,來驗證上面的說法:

type MyContext struct {
    // 這裏的 Context 是我 copy 出來的,因此前面不用加 context.
    Context
}

func main() {
    childCancel := true

    parentCtx, parentFunc := WithCancel(Background())
    mctx := MyContext{parentCtx}

    childCtx, childFun := WithCancel(mctx)

    if childCancel {
        childFun()
    } else {
        parentFunc()
    }

    fmt.Println(parentCtx)
    fmt.Println(mctx)
    fmt.Println(childCtx)

    // 防止主協程退出太快,子協程來不及打印 
    time.Sleep(10 * time.Second)
}

我自已在 else 裏添加的打印語句我就不貼出來了,感興趣的能夠本身動手實驗下。咱們看下三個 context 的打印結果:

context.Background.WithCancel
{context.Background.WithCancel}
{context.Background.WithCancel}.WithCancel

果真,mctx,childCtx 和正常的 parentCtx 不同,由於它是一個自定義的結構體類型。

else 這段代碼說明,若是把 ctx 強行塞進一個結構體,並用它做爲父節點,調用 WithCancel 函數構建子節點 context 的時候,Go 會新啓動一個協程來監控取消信號,明顯有點浪費嘛。

再來講一下,select 語句裏的兩個 case 其實都不能刪。

select {
    case <-parent.Done():
        child.cancel(false, parent.Err())
    case <-child.Done():
}

第一個 case 說明當父節點取消,則取消子節點。若是去掉這個 case,那麼父節點取消的信號就不能傳遞到子節點。

第二個 case 是說若是子節點本身取消了,那就退出這個 select,父節點的取消信號就不用管了。若是去掉這個 case,那麼極可能父節點一直不取消,這個 goroutine 就泄漏了。固然,若是父節點取消了,就會重複讓子節點取消,不過,這也沒什麼影響嘛。

timerCtx

timerCtx 基於 cancelCtx,只是多了一個 time.Timer 和一個 deadline。Timer 會在 deadline 到來時,自動取消 context。

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

    deadline time.Time
}

timerCtx 首先是一個 cancelCtx,因此它能取消。看下 cancel() 方法:

func (c *timerCtx) cancel(removeFromParent bool, err error) {
    // 直接調用 cancelCtx 的取消方法
    c.cancelCtx.cancel(false, err)
    if removeFromParent {
        // 從父節點中刪除子節點
        removeChild(c.cancelCtx.Context, c)
    }
    c.mu.Lock()
    if c.timer != nil {
        // 關掉定時器,這樣,在deadline 到來時,不會再次取消
        c.timer.Stop()
        c.timer = nil
    }
    c.mu.Unlock()
}

建立 timerCtx 的方法:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

WithTimeout 函數直接調用了 WithDeadline,傳入的 deadline 是當前時間加上 timeout 的時間,也就是從如今開始再通過 timeout 時間就算超時。也就是說,WithDeadline 須要用的是絕對時間。重點來看它:

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) {
    if cur, ok := parent.Deadline(); ok && cur.Before(deadline) {
        // 若是父節點 context 的 deadline 早於指定時間。直接構建一個可取消的 context。
        // 緣由是一旦父節點超時,自動調用 cancel 函數,子節點也會隨之取消。
        // 因此不用單獨處理子節點的計時器時間到了以後,自動調用 cancel 函數
        return WithCancel(parent)
    }
    
    // 構建 timerCtx
    c := &timerCtx{
        cancelCtx: newCancelCtx(parent),
        deadline:  deadline,
    }
    // 掛靠到父節點上
    propagateCancel(parent, c)
    
    // 計算當前距離 deadline 的時間
    d := time.Until(deadline)
    if d <= 0 {
        // 直接取消
        c.cancel(true, DeadlineExceeded) // deadline has already passed
        return c, func() { c.cancel(true, Canceled) }
    }
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.err == nil {
        // d 時間後,timer 會自動調用 cancel 函數。自動取消
        c.timer = time.AfterFunc(d, func() {
            c.cancel(true, DeadlineExceeded)
        })
    }
    return c, func() { c.cancel(true, Canceled) }
}

也就是說仍然要把子節點掛靠到父節點,一旦父節點取消了,會把取消信號向下傳遞到子節點,子節點隨之取消。

有一個特殊狀況是,若是要建立的這個子節點的 deadline 比父節點要晚,也就是說若是父節點是時間到自動取消,那麼必定會取消這個子節點,致使子節點的 deadline 根本不起做用,由於子節點在 deadline 到來以前就已經被父節點取消了。

這個函數的最核心的一句是:

c.timer = time.AfterFunc(d, func() {
    c.cancel(true, DeadlineExceeded)
})

c.timer 會在 d 時間間隔後,自動調用 cancel 函數,而且傳入的錯誤就是 DeadlineExceeded

var DeadlineExceeded error = deadlineExceededError{}

type deadlineExceededError struct{}

func (deadlineExceededError) Error() string   { return "context deadline exceeded" }

也就是超時錯誤。

valueCtx

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

它實現了兩個方法:

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

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

因爲它直接將 Context 做爲匿名字段,所以僅管它只實現了 2 個方法,其餘方法繼承自父 context。但它仍然是一個 Context,這是 Go 語言的一個特色。

建立 valueCtx 的函數:

func WithValue(parent Context, key, val interface{}) Context {
    if key == nil {
        panic("nil key")
    }
    if !reflect.TypeOf(key).Comparable() {
        panic("key is not comparable")
    }
    return &valueCtx{parent, key, val}
}

對 key 的要求是可比較,由於以後須要經過 key 取出 context 中的值,可比較是必須的。

經過層層傳遞 context,最終造成這樣一棵樹:

valueCtx

和鏈表有點像,只是它的方向相反: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 節點的過程實際上就是建立鏈表節點的過程。兩個節點的 key 值是能夠相等的,但它們是兩個不一樣的 context 節點。查找的時候,會向上查找到最後一個掛載的 context 節點,也就是離得比較近的一個父節點 context。因此,總體上而言,用 WithValue 構造的實際上是一個低效率的鏈表。

若是你接手過項目,確定經歷過這樣的窘境:在一個處理過程當中,有若干子函數、子協程。各類不一樣的地方會向 context 裏塞入各類不一樣的 k-v 對,最後在某個地方使用。

你根本就不知道何時什麼地方傳了什麼值?這些值會不會被「覆蓋」(底層是兩個不一樣的 context 節點,查找的時候,只會返回一個結果)?你確定會崩潰的。

而這也是 context.Value 最受爭議的地方。不少人建議儘可能不要經過 context 傳值。

如何使用 context

context 使用起來很是方便。源碼裏對外提供了一個建立根節點 context 的函數:

func Background() Context

background 是一個空的 context, 它不能被取消,沒有值,也沒有超時時間。

有了根節點 context,又提供了四個函數建立子節點 context:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

context 會在函數傳遞間傳遞。只須要在適當的時間調用 cancel 函數向 goroutines 發出取消信號或者調用 Value 函數取出 context 中的值。

在官方博客裏,對於使用 context 提出了幾點建議:

  1. Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx.
  2. Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use.
  3. Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
  4. The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines.

我翻譯一下:

  1. 不要將 Context 塞到結構體裏。直接將 Context 類型做爲函數的第一參數,並且通常都命名爲 ctx。
  2. 不要向函數傳入一個 nil 的 context,若是你實在不知道傳什麼,標準庫給你準備好了一個 context:todo。
  3. 不要把本應該做爲函數參數的類型塞到 context 中,context 存儲的應該是一些共同的數據。例如:登錄的 session、cookie 等。
  4. 同一個 context 可能會被傳遞到多個 goroutine,別擔憂,context 是併發安全的。

傳遞共享的數據

對於 Web 服務端開發,每每但願將一個請求處理的整個過程串起來,這就很是依賴於 Thread Local(對於 Go 可理解爲單個協程所獨有) 的變量,而在 Go 語言中並無這個概念,所以須要在函數調用的時候傳遞 context。

package main

import (
    "context"
    "fmt"
)

func main() {
    ctx := context.Background()
    process(ctx)

    ctx = context.WithValue(ctx, "traceId", "qcrao-2019")
    process(ctx)
}

func process(ctx context.Context) {
    traceId, ok := ctx.Value("traceId").(string)
    if ok {
        fmt.Printf("process over. trace_id=%s\n", traceId)
    } else {
        fmt.Printf("process over. no trace_id\n")
    }
}

運行結果:

process over. no trace_id
process over. trace_id=qcrao-2019

第一次調用 process 函數時,ctx 是一個空的 context,天然取不出來 traceId。第二次,經過 WithValue 函數建立了一個 context,並賦上了 traceId 這個 key,天然就能取出來傳入的 value 值。

固然,現實場景中多是從一個 HTTP 請求中獲取到的 Request-ID。因此,下面這個樣例可能更適合:

const requestIDKey int = 0

func WithRequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(
        func(rw http.ResponseWriter, req *http.Request) {
            // 從 header 中提取 request-id
            reqID := req.Header.Get("X-Request-ID")
            // 建立 valueCtx。使用自定義的類型,不容易衝突
            ctx := context.WithValue(
                req.Context(), requestIDKey, reqID)
            
            // 建立新的請求
            req = req.WithContext(ctx)
            
            // 調用 HTTP 處理函數
            next.ServeHTTP(rw, req)
        }
    )
}

// 獲取 request-id
func GetRequestID(ctx context.Context) string {
    ctx.Value(requestIDKey).(string)
}

func Handle(rw http.ResponseWriter, req *http.Request) {
    // 拿到 reqId,後面能夠記錄日誌等等
    reqID := GetRequestID(req.Context())
    ...
}

func main() {
    handler := WithRequestID(http.HandlerFunc(Handle))
    http.ListenAndServe("/", handler)
}

取消 goroutine

咱們先來設想一個場景:打開外賣的訂單頁,地圖上顯示外賣小哥的位置,並且是每秒更新 1 次。app 端向後臺發起 websocket 鏈接(現實中多是輪詢)請求後,後臺啓動一個協程,每隔 1 秒計算 1 次小哥的位置,併發送給端。若是用戶退出此頁面,則後臺須要「取消」此過程,退出 goroutine,系統回收資源。

後端可能的實現以下:

func Perform() {
    for {
        calculatePos()
        sendResult()
        time.Sleep(time.Second)
    }
}

若是須要實現「取消」功能,而且在不瞭解 context 功能的前提下,可能會這樣作:給函數增長一個指針型的 bool 變量,在 for 語句的開始處判斷 bool 變量是發由 true 變爲 false,若是改變,則退出循環。

上面給出的簡單作法,能夠實現想要的效果,沒有問題,可是並不優雅,而且一旦協程數量多了以後,而且各類嵌套,就會很麻煩。優雅的作法,天然就要用到 context。

func Perform(ctx context.Context) {
    for {
        calculatePos()
        sendResult()

        select {
        case <-ctx.Done():
            // 被取消,直接返回
            return
        case <-time.After(time.Second):
            // block 1 秒鐘 
        }
    }
}

主流程多是這樣的:

ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
go Perform(ctx)

// ……
// app 端返回頁面,調用cancel 函數
cancel()

注意一個細節,WithTimeOut 函數返回的 context 和 cancelFun 是分開的。context 自己並無取消函數,這樣作的緣由是取消函數只能由外層函數調用,防止子節點 context 調用取消函數,從而嚴格控制信息的流向:由父節點 context 流向子節點 context。

防止 goroutine 泄漏

前面那個例子裏,goroutine 仍是會本身執行完,最後返回,只不過會多浪費一些系統資源。這裏改編一個「若是不用 context 取消,goroutine 就會泄漏的例子」,來自參考資料:【避免協程泄漏】

func gen() <-chan int {
    ch := make(chan int)
    go func() {
        var n int
        for {
            ch <- n
            n++
            time.Sleep(time.Second)
        }
    }()
    return ch
}

這是一個能夠生成無限整數的協程,但若是我只須要它產生的前 5 個數,那麼就會發生 goroutine 泄漏:

func main() {
    for n := range gen() {
        fmt.Println(n)
        if n == 5 {
            break
        }
    }
    // ……
}

當 n == 5 的時候,直接 break 掉。那麼 gen 函數的協程就會執行無限循環,永遠不會停下來。發生了 goroutine 泄漏。

用 context 改進這個例子:

func gen(ctx context.Context) <-chan int {
    ch := make(chan int)
    go func() {
        var n int
        for {
            select {
            case <-ctx.Done():
                return
            case ch <- n:
                n++
                time.Sleep(time.Second)
            }
        }
    }()
    return ch
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // 避免其餘地方忘記 cancel,且重複調用不影響

    for n := range gen(ctx) {
        fmt.Println(n)
        if n == 5 {
            cancel()
            break
        }
    }
    // ……
}

增長一個 context,在 break 前調用 cancel 函數,取消 goroutine。gen 函數在接收到取消信號後,直接退出,系統回收資源。

context 真的這麼好嗎

讀徹底文,你必定有這種感受:context 就是爲 server 而設計的。說什麼處理一個請求,須要啓動多個 goroutine 並行地去處理,而且在這些 goroutine 之間還要傳遞一些共享的數據等等,這些都是寫一個 server 要作的事。

沒錯,Go 很適合寫 server,但它終歸是一門通用的語言。你在用 Go 作 Leetcode 上面的題目的時候,確定不會認爲它和通常的語言有什麼差異。因此,不少特性好很差,應該從 Go 只是一門普通的語言,很擅長寫 server 的角度來看。

從這個角度來看,context 並無那麼美好。Go 官方建議咱們把 Context 做爲函數的第一個參數,甚至連名字都準備好了。這形成一個後果:由於咱們想控制全部的協程的取消動做,因此須要在幾乎全部的函數里加上一個 Context 參數。很快,咱們的代碼裏,context 將像病毒同樣擴散的處處都是。

在參考資料【Go2 應該去掉 context】這篇英文博客裏,做者甚至調侃說:若是要把 Go 標準庫的大部分函數都加上 context 參數的話,例以下面這樣:

n, err := r.Read(context.TODO(), p)

就給我來一槍吧!

原文是這樣說的:put a bullet in my head, please.我當時看到這句話的時候,會心一笑。這可能就是陶淵明說的:每有會意,便欣然忘食。固然,我是在晚飯會看到這句話的。

爲了表達本身對 context 並無什麼好感,做者接着又說了一句:If you use ctx.Value in my (non-existent) company, you’re fired. 簡直太幽默了,哈哈。

另外,像 WithCancelWithDeadlineWithTimeoutWithValue 這些建立函數,其實是建立了一個個的鏈表結點而已。咱們知道,對鏈表的操做,一般都是 O(n) 複雜度的,效率不高。

那麼,context 包到底解決了什麼問題呢?答案是:cancelation。僅管它並不完美,但它確實很簡潔地解決了問題。

總結

到這裏,整個 context 包的內容就所有講完了。源碼很是短,很適合學習,必定要去讀一下。

context 包是 Go 1.7 引入的標準庫,主要用於在 goroutine 之間傳遞取消信號、超時時間、截止時間以及一些共享的值等。它並非太完美,但幾乎成了併發控制和超時控制的標準作法。

使用上,先建立一個根節點的 context,以後根據庫提供的四個函數建立相應功能的子節點 context。因爲它是併發安全的,因此能夠放心地傳遞。

當使用 context 做爲函數參數時,直接把它放在第一個參數的位置,而且命名爲 ctx。另外,不要把 context 嵌套在自定義的類型裏。

最後,你們下次在看到代碼裏有用到 context 的,觀察下是怎麼使用的,確定逃不出咱們講的幾種類型。熟悉以後會發現:context 可能並不完美,但它確實簡潔高效地解決了問題。

參考資料

【context 官方博客】https://blog.golang.org/context

【今日頭條構建Go的實踐】https://zhuanlan.zhihu.com/p/...

【飛雪無情的博客】https://www.flysnow.org/2017/...

【context 源碼】https://juejin.im/post/5a6873...

【騰訊雲源碼閱讀】https://cloud.tencent.com/dev...

【更宏觀地一些思考,english】https://siadat.github.io/post...

【避免協程泄漏】https://rakyll.org/leakingctx/

【應用分類】https://dreamerjonson.com/201...

【官方文檔示例翻譯版】https://brantou.github.io/201...

【例子,english】http://p.agnihotry.com/post/u...

【Go2 應該去掉 context】https://faiface.github.io/pos...

【源碼,比較詳細】https://juejin.im/post/5c1514...

【Golang Context 是好的設計嗎?】https://segmentfault.com/a/11...

【今日頭條的 Go 實踐】https://36kr.com/p/5073181

【實例】https://zhuanlan.zhihu.com/p/...

QR

相關文章
相關標籤/搜索