Go Context 實踐指南:以生產環境問題爲例
- 背景
Go 語言中的 context(上下文) 對於 Go 程序員來說應該是司空見慣, 很多都會不假思索的將 context 類型入參作爲函數的第一個參數.
最近因爲在生產環境處理過一個和 context 有關的問題, 因此希望可以藉助這個實際問題說說 context 使用上的注意事項. 類似 “context 應該使用參數傳遞不能作爲 struct 成員” 的八股知識不在本文討論範圍了.
- 怎麼確定函數是否要帶 context 參數
所有需要參與請求鏈路控制(如取消、超時、元數據傳遞)的函數,應該將 context 作爲第一參數。
注意這裏和 “請求鏈路” 關聯起來了, 這個請求可以是 HTTP 請求、RPC 掉用、外部存儲掉用 (如 DB) 和可取消的定時任務等等.
相應的, 如果我們的函數是求 “兩個 slice 的交集”,“遍歷樹” 這樣和請求無關的功能, 則不需要帶上 context 參數.
下面以一個定時任務的精簡代碼做示例:
package main
import (
"context"
"fmt"
"time"
)
func add(a, b int) int {
return a + b
}
func runTask(ctx context.Context, cancel func()) {
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
fmt.Println("done")
return
case <-ticker.C:
fmt.Println("tick")
v := add(1, 2)
if v < 0 {
cancel() // 主動取消runTask任務
}
}
}
}
func demo(ctx context.Context) {
taskCtx, cancel := context.WithCancel(ctx)
defer cancel() // 被動取消runTask任務
go runTask(taskCtx, cancel)
<-ctx.Done()
}
func main() {
demo(context.Background())
}
這個程序不會退出, 會啓用一個協程一直運行一個定時任務。上面涉及的兩個函數 runTask 和 add 就正好分別對應了必須帶上 context 和無需帶上 context 兩個場景:
-
runTask 是一個協程,它需要控制:外部條件不滿足別動取消和內部條件不滿足主動取消。因此要帶上 context。
-
add 函數是完成一個非常獨立的任務,此處對應的是對兩個入參做加法,運行完給出結果結束了, 因此不需要帶上 context。
-
帶 cancel 能力的 context 實現方法
如下是 go 庫源碼裏面對有 cancel 能力的 context 的定義:
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 atomic.Value // set to non-nil by the first cancel call
cause error // set to non-nil by the first cancel call
}
可以看到,它就是通過 chan 實現的 cancel 能力。這也解釋了 2 中的 demo 爲啥可以用 select 去監聽 ctx.Done() 了。如果你只是需要進行協程之間的控制,比如上面的例子,會有兩種方法: 一種是用 cancelCtx,另一個是自定義一個 chan 去實現。 這個看實際需要了。
- 帶 timeout 能力的 context 實現方法
如下是 go 庫源碼裏面對有 timeout 能力的 context 的定義:
type timerCtx struct {
cancelCtx // 注意這裏是cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
可以看到,它是給 cancelCtx 配了一個定時器實現的。
- 生產環境遇到的問題
如下是經過我抽象之後的生產環境的代碼:
func demo(flag bool) {
ctx1, cancel1 := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel1()
// 得到一個errgroup
group, ctx2 := errgroup.WithContext(ctx1)
group.SetLimit(1)
for i := 0; i < 2; i++ {
group.Go(func() error {
fmt.Println("do group task")
return nil
})
}
if err := group.Wait(); err != nil {
log.Fatal(err)
return
}
// 基於ctx2得到ctx3
ctx3, cancel3 := context.WithCancel(ctx2)
defer cancel3()
if flag {
if ctx3.Err() != nil { // 該分支處理了ctx的狀態
fmt.Println("ctx2 cancelled")
return
}
fmt.Println("do task1")
} else { // 該分支沒有處理ctx的狀態
fmt.Println("do task2")
}
}
請不要糾結是怎麼寫出這種代碼。 在這裏我想通過這個代碼說明使用 context 會經常遇到的陷阱:
1. ctx 的狀態要被處理纔有意義。 我們知道 context 是設計用來進行 “請求鏈路控制” 的。而所謂 “控制” 的落地方式就是通過 ctx.Done()可以感知到事件從而進行控制,如果代碼都不去處理 context 的事件信號,那這個 context 也就沒有發揮作用了。 上述代碼中 “do task2”的地址就沒有處理 ctx.Done 事件
2. errgroup.Wait() 函數會將它關聯的 ctx 取消掉。這個時候再去繼承出一個新的 context, 新的 context 也會被取消。因此代碼中的 “do task1” 永遠沒有機會執行到。很多人都知道 context 可以形成上下文鏈, 但是要注意鏈上的 context 是不是被取消了。
- 總結
context 作爲 Go 語言的特色之一,確實給併發編程帶來了很多便捷性。 但是在使用的時候還是要多加小心。 避免造成非預期的問題。個人使用 Go 的時間不多,這次也是藉助生產環境遇到的問題較深入的探討下 context。如果有不對的地方,歡迎分享指正。 如果對您有所幫助,那是高興的事情。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/_Yj06_lg2vlGAeBGn_XOyw