深度解密Go語言之context

Stefno發表於2019-06-13

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 同時工作:有些去資料庫拿資料,有些呼叫下游介面獲取相關資料……

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/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/understanding_the_context_package_in_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

QR

相關文章