深度解密 Go 語言之 context

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

這篇文章依然想嘗試全面、深入地去研究。文章相比往期而言,整體不長,希望你看完可以有所收穫!

貼上文章的目錄:

什麼是 context

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

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

隨着 context 包的引入,標準庫中很多接口因此加上了 context 參數,例如 database/sql 包。context 幾乎成爲了併發控制和超時控制的標準做法。

context.Context 類型的值可以協調多個 groutine 中的代碼執行 “取消” 操作,並且可以存儲鍵值對。最重要的是它是併發安全的。

與它協作的 API 都可以由外部控制執行 “取消” 操作,例如:取消一個 HTTP 請求的執行。

沒看懂?沒關係,先往後看。

爲什麼有 context

Go 常用來寫後臺服務,通常只需要幾行代碼,就可以搭建一個 http server。

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

這些 goroutine 需要共享這個請求的基本數據,例如登陸的 token,處理請求的最大超時時間(如果超過此值再返回數據,請求方因爲超時接收不到)等等。當請求被取消或是處理時間太長,這有可能是使用者關閉了瀏覽器或是已經超過了請求方規定的超時時間,請求方直接放棄了這次請求結果。這時,所有正在爲這個請求工作的 goroutine 需要快速退出,因爲它們的 “工作成果” 不再被需要了。在相關聯的 goroutine 都退出後,系統就可以回收相關的資源。

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

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

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

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

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

context 底層實現原理

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

整體概覽

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

先給大家看一張整體的圖:

SC6AkK

上面這張表展示了 context 的所有函數、接口、結構體,可以縱覽全局,可以在讀完文章後,再回頭細看。

整體類圖如下:

接口

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 樹。當調用左圖中標紅 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,最終形成這樣一棵樹:

和鏈表有點像,只是它的方向相反: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 bulletinmyhead,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/26695984

【飛雪無情的博客】https://www.flysnow.org/2017/05/12/go-in-action-go-context.html

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

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

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

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

【應用分類】https://dreamerjonson.com/2019/05/09/golang-73-context/index.html

【官方文檔示例翻譯版】https://brantou.github.io/2017/05/19/go-concurrency-patterns-context/

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

【Go2 應該去掉 context】https://faiface.github.io/post/context-should-go-away-go2/

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

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

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

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/GpVy1eB5Cz_t-dhVC6BJNw