一起聊聊 Go Context 的正確使用姿勢
大家好,我是煎魚。
在 Go 語言中,Goroutine(協(xié)程),也就是關(guān)鍵字 go 是一個家喻戶曉的高級用法。這起的非常妙,說到 Go,就會想到這一門語言,想到 goroutine 這一關(guān)鍵字,而與之關(guān)聯(lián)最深的就是 context。

背景
平時在 Go 工程中開發(fā)中,幾乎所有服務(wù)端(例如:HTTP Server)的默認實現(xiàn),都在處理請求時新起 goroutine 進行處理。
但一開始存在一個問題,那就是當(dāng)一個請求被取消或超時時,所有在該請求上工作的 goroutines 應(yīng)該迅速退出,以便系統(tǒng)可以回收他們正在使用的任何資源。
當(dāng)年可沒有 context 標準庫。很折騰。因此 Go 官方在 2014 年正式宣發(fā)了 context 標準庫,形成一個完整的閉環(huán)。
但有了 context 標準庫,Go 愛好者們又奇怪了,前段時間我就被問到了:“Go context 的正確使用姿勢是怎么樣的”?
(一張忘記在哪里被問的隱形截圖)
今天這篇文章就由煎魚帶你看看。
Context 用法
在 Go context 用法中,我們常常將其與 select 關(guān)鍵字結(jié)合使用,用于監(jiān)聽其是否結(jié)束、取消等。
代碼如下:
const shortDuration = 1 * time.Millisecond
func main() {
ctx, cancel := context.WithTimeout(context.Background(), shortDuration)
defer cancel()
select {
case <-time.After(1 * time.Second):
fmt.Println("腦子進煎魚了")
case <-ctx.Done():
fmt.Println(ctx.Err())
}
}
輸出結(jié)果:
context deadline exceeded
如果是更進一步結(jié)合 goroutine 的話,常見的例子是:
func(ctx context.Context) <-chan int {
dst := make(chan int)
n := 1
go func() {
for {
select {
case <-ctx.Done():
return
case dst <- n:
n++
}
}
}()
return dst
}
我們平時工程中會起很多的 goroutine,這時候會在 goroutine 內(nèi)結(jié)合 for+select,針對 context 的事件進行處理,達到跨 goroutine 控制的目的。
正確的使用姿勢
對第三方調(diào)用要傳入 context
在 Go 語言中,Context 的默認支持已經(jīng)是約定俗稱的規(guī)范了。因此在我們對第三方有調(diào)用訴求的時候,要傳入 context:
func main() {
req, err := http.NewRequest("GET", "https://eddycjy.com/", nil)
if err != nil {
fmt.Printf("http.NewRequest err: %+v", err)
return
}
ctx, cancel := context.WithTimeout(req.Context(), 50*time.Millisecond)
defer cancel()
req = req.WithContext(ctx)
resp, err := http.DefaultClient.Do(req)
if err != nil {
fmt.Printf("http.DefaultClient.Do err: %+v", err)
return
}
defer resp.Body.Close()
}
這樣子由于第三方開源庫已經(jīng)實現(xiàn)了根據(jù) context 的超時控制,那么當(dāng)你所傳入的時間到達時,將會中斷調(diào)用。
若你發(fā)現(xiàn)第三方開源庫沒支持 context,那建議趕緊跑,換一個。免得在微服務(wù)體系下出現(xiàn)級聯(lián)故障,還沒有簡單的手段控制,那就很麻煩了。
不要將上下文存儲在結(jié)構(gòu)類型中
大家會發(fā)現(xiàn),在 Go 語言中,所有的第三方開源庫,業(yè)務(wù)代碼。清一色的都會將 context 放在方法的一個入?yún)?shù),作為首位形參。
例如:

標準要求:每個方法的第一個參數(shù)都將 context 作為第一個參數(shù),并使用 ctx 變量名慣用語。
當(dāng)然,我們也不能一桿子打死所有情況。確實存在極少數(shù)是把 context 放在結(jié)構(gòu)體中的。基本常見于:
底層基礎(chǔ)庫。 DDD 結(jié)構(gòu)。
每個請求都是獨立的,context 自然每個都不一樣,想清楚自己的應(yīng)用使用場景很重要,否則遵循 Go 基本規(guī)范就好。
在真實案例來看,有的 Leader 會單純?yōu)榱瞬幌腩l繁傳 context 而設(shè)計成結(jié)構(gòu)體,結(jié)果導(dǎo)致一線 RD 就得天天 NewXXX,甚至有時候忘記了,還得背個小鍋。
函數(shù)調(diào)用鏈必須傳播上下文
我們會把 context 作為方法首位,本質(zhì)目的是為了傳播 context,自行完整調(diào)用鏈路上的各類控制:
func List(ctx context.Context, db *sqlx.DB) ([]User, error) {
ctx, span := trace.StartSpan(ctx, "internal.user.List")
defer span.End()
users := []User{}
const q = `SELECT * FROM users`
if err := db.SelectContext(ctx, &users, q); err != nil {
return nil, errors.Wrap(err, "selecting users")
}
return users, nil
}
像在上述例子中,我們會把所傳入方法的 context 一層層的傳進去下一級方法。這里就是將外部的 context 傳入 List 方法,再傳入 SQL 執(zhí)行的方法,解決了 SQL 執(zhí)行語句的時間問題。
context 的繼承和派生
在 Go 標準庫 context 中具有以下派生 context 的標準方法:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
代碼例子如下:
func handle(w http.ResponseWriter, req *http.Request) {
// parent context
timeout, _ := time.ParseDuration(req.FormValue("timeout"))
ctx, cancel := context.WithTimeout(context.Background(), timeout)
// chidren context
newCtx, cancel := context.WithCancel(ctx)
defer cancel()
// do something...
}
一般會有父級 context 和子級 context 的區(qū)別,我們要保證在程序的行為中上下文對于多個 goroutine 同時使用是安全的。并且存在父子級別關(guān)系,父級 context 關(guān)閉或超時,可以繼而影響到子級 context 的程序。
不傳遞 nil context
很多時候我們在創(chuàng)建 context 時,還不知道其具體的作用和下一步用途是什么。
這種時候大家可能會直接使用 context.Background 方法:
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
func Background() Context {
return background
}
func TODO() Context {
return todo
}
但在實際的 context 建議中,我們會建議使用 context.TODO 方法來創(chuàng)建頂級的 context,直到弄清楚實際 Context 的下一步用途,再進行變更。
context 僅傳遞必要的值
我們在使用 context 作為上下文時,經(jīng)常有信息傳遞的訴求。像是在 gRPC 中就會有 metadata 的概念,而在 gin 中就會自己封裝 context 作為參數(shù)管理。
Go 標準庫 context 也有提供相關(guān)的方法:
type Context
func WithValue(parent Context, key, val interface{}) Context
代碼例子如下:
func main() {
type favContextKey string
f := func(ctx context.Context, k favContextKey) {
if v := ctx.Value(k); v != nil {
fmt.Println("found value:", v)
return
}
fmt.Println("key not found:", k)
}
k := favContextKey("腦子進")
ctx := context.WithValue(context.Background(), k, "煎魚")
f(ctx, k)
f(ctx, favContextKey("小咸魚"))
}
輸出結(jié)果:
found value: 煎魚
key not found: 小咸魚
在規(guī)范中,我們建議 context 在傳遞時,僅攜帶必要的參數(shù)給予其他的方法,或是 goroutine。甚至在 gRPC 中會做嚴格的出、入上下文參數(shù)的控制。
在業(yè)務(wù)場景上,context 傳值適用于傳必要的業(yè)務(wù)核心屬性,例如:租戶號、小程序ID 等。不要將可選參數(shù)放到 context 中,否則可能會一團糟。
總結(jié)
對第三方調(diào)用要傳入 context,用于控制遠程調(diào)用。 不要將上下文存儲在結(jié)構(gòu)類型中,盡可能的作為函數(shù)第一位形參傳入。 函數(shù)調(diào)用鏈必須傳播上下文,實現(xiàn)完整鏈路上的控制。 context 的繼承和派生,保證父、子級 context 的聯(lián)動。 不傳遞 nil context,不確定的 context 應(yīng)當(dāng)使用 TODO。 context 僅傳遞必要的值,不要讓可選參數(shù)揉在一起。
關(guān)注煎魚,吸取他的知識 ??

你好,我是煎魚。高一折騰過前端,參加過國賽拿了獎,大學(xué)搞過 PHP。現(xiàn)在整 Go,在公司負責(zé)微服務(wù)架構(gòu)等相關(guān)工作推進和研發(fā)。
從大學(xué)開始靠自己賺生活費和學(xué)費,到出版 Go 暢銷書《Go 語言編程之旅》,再到獲得 GOP(Go 領(lǐng)域最有觀點專家)榮譽,點擊藍字查看我的出書之路。
日常分享高質(zhì)量文章,輸出 Go 面試、工作經(jīng)驗、架構(gòu)設(shè)計,加微信拉讀者交流群,記得點贊!
