圖解Go語(yǔ)言Context
今天與你分享下 Go 語(yǔ)言里面 context 包的相關(guān)知識(shí)。
一般新技術(shù)的出現(xiàn)都是為了解決現(xiàn)有技術(shù)存在的問題或者可以提供更優(yōu)雅方便的實(shí)現(xiàn)方式,那我們就要想想 contex 包能解決什么問題?
關(guān)于 context 能解決什么問題,推薦你看看這兩篇文章:
飛雪大佬的 《Go 語(yǔ)言實(shí)戰(zhàn)筆記(二十)| Go Context》[1]
和
這兩篇已經(jīng)寫得足夠清晰明了,這里我主要想假設(shè)一些場(chǎng)景,更方便大家理解問題:
假設(shè)你開啟了一個(gè)函數(shù),你需要將一些常用的值傳遞給下游函數(shù),但是不能通過(guò)函數(shù)參數(shù)傳遞,怎么辦? 假設(shè)你開啟了一個(gè)協(xié)程 A,協(xié)程 A 衍生出很多子協(xié)程,這些子協(xié)程又衍生出子協(xié)程,如果協(xié)程 A 所完成的任務(wù)“成果”不再需要,那我們?cè)趺赐ㄖ苌龅淖訁f(xié)程及時(shí)退出并釋放占用的系統(tǒng)資源呢? 假設(shè)一個(gè)任務(wù)需要在 2s 內(nèi)完成,如果超時(shí),如何優(yōu)雅地退出返回呢? 假設(shè)一個(gè)任務(wù)需要在中午 12 點(diǎn)完成,如果到點(diǎn)沒有完成,又該如何優(yōu)雅地退出呢?
好了,帶著這些問題,我們接著往下看。
context 接口
理解 context 包,核心是需要理解 Context 接口:
type Context interface {
Done() <-chan struct{}
Err() error
Deadline() (deadline time.Time, ok bool)
Value(key interface{}) interface{}
}
這個(gè)接口有四個(gè)方法:
Done() 返回只讀的 channel,在 goroutine 中,如果該 channel 可讀,則意味著父 context 發(fā)起了取消操作或者是時(shí)間到期,理解這一點(diǎn)非常重要。
Err() 返回錯(cuò)誤,表示 channel 被關(guān)閉的原因,被取消還是超時(shí)。
Deadline() 獲取設(shè)置的截止時(shí)間,第一個(gè)是截止時(shí)間,表示到了這個(gè)點(diǎn),context 會(huì)自動(dòng)發(fā)起取消操作;第二個(gè)表示是否設(shè)置了截止時(shí)間。
Value() 方法獲取 context 上綁定的值,是一個(gè)鍵值對(duì),這個(gè)值一般是線程安全的。
Done() 是最常用的方法,經(jīng)常與 select-case 配合使用,因?yàn)?context 取消的時(shí)候,我們就可以得到一個(gè)可讀的 channel,以此來(lái)判斷是否收到 context 取消的信號(hào),最經(jīng)典的用法可以在 context 包的代碼中找到:
func Stream(ctx context.Context, out chan<- Value) error {
for {
v, err := DoSomething(ctx)
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case out <- v:
}
}
}
衍生 context
我們不需要自己實(shí)現(xiàn) Context 接口,源碼包已經(jīng)為我們提供了兩個(gè)實(shí)現(xiàn)接口的方法,分別是 Background() 和 TODO();
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
func Background() Context {
return background
}
func TODO() Context {
return todo
}
context.Background() 返回空的 context,通常用在 main 函數(shù)里,作為根 context 衍生出子 context。
context.TODO() 也是返回空 context。主要用在還不清楚使用什么類型的 context 的時(shí)候,便于后期重構(gòu),先用它占個(gè)位。
它們兩本質(zhì)上是 emptyCtx 類型,不能被取消,沒有值,也沒有超時(shí)時(shí)間。
有了上面兩個(gè)根 context,就可以衍生出子 context,源碼包為我們提供了一系列 withXXX 函數(shù)用于生成子 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
這四個(gè)函數(shù)的第一個(gè)參數(shù)都是父 context,可以理解為基于父 context 生成子 context,即衍生子 context。
WithCancel() 基于父 context,返回子 context 和取消函數(shù);
WithDeadline() 基于父 context,返回帶截止時(shí)間的子 context 和取消函數(shù);
WithTimeout() 基于父 context,返回帶超時(shí)時(shí)間的子 context 和取消函數(shù);
WithValue() 基于父 context,返回綁定鍵值對(duì)的子 context,沒有取消函數(shù);
前三個(gè)函數(shù)都會(huì)返回取消函數(shù),需要注意的是只有創(chuàng)建該 context 的協(xié)程才能調(diào)用取消函數(shù),且不推薦將取消函數(shù)作為參數(shù)傳遞。
我們可以調(diào)用取消函數(shù)取消一個(gè) context,以及這個(gè) context 下面所有的子 context。
通過(guò)這些函數(shù),就能生成一棵 context 樹,樹的每個(gè)節(jié)點(diǎn)都可以有任意多個(gè)子節(jié)點(diǎn),節(jié)點(diǎn)層級(jí)可以有任意多個(gè)。
context 樹
我們一起來(lái)看下基于兩個(gè)根 context 可以創(chuàng)建的 context 樹是什么樣。
兩層樹
rootCtx := context.Background()
childCtx := context.WithValue(rootCtx, "request_Id", "seekload")
上面的代碼基于根 context 創(chuàng)建了兩層 context 樹,rootCtx 衍生出 childCtx,并攜帶鍵值對(duì) {"request_Id" : "seekload"}。
三層樹
rootCtx := context.Background()
childCtx := context.WithValue(rootCtx, "request_Id", "seekload")
childOfChildCtx, cancelFunc := context.WithCancel(childCtx)
基于兩層樹,childCtx 衍生出 childOfChildCtx,含有鍵值對(duì)并且具有取消功能。
多層樹
rootCtx := context.Background()
childCtx1 := context.WithValue(rootCtx, "request_Id", "seekload")
childCtx2, cancelFunc := context.WithCancel(childCtx1)
childCtx3 := context.WithValue(rootCtx, "user_Id", "user_100")
上面的代碼:
rootCtx 是根 context; rootCtx 衍生出 childCtx1,并攜帶鍵值對(duì) {"request_Id" : "seekload"}; childCtx1 衍生出 childCtx2,可以取消 context; rootCtx 衍生出 childCtx3,攜帶鍵值對(duì) {"user_Id" : "user_100"};
層級(jí)關(guān)系就像下面這樣:

我們可以在任一結(jié)點(diǎn) context 上創(chuàng)建子 context,比如從 childCtx1 衍生 childCtx4:
childCtx4 := context.WithValue(childCtx1, "token", "token_some")
層級(jí)關(guān)系就變成這樣了:

Talk is cheap. Show me the code.
看到這里,可能你還是不知道怎么去用 context 包,接下來(lái)我們結(jié)合著示例展示下 withXXX 函數(shù)的使用方法。
如何使用
context.WithCancel()
context.WithCancel() 用于取消信號(hào),直接來(lái)看例子:
func main() {
ctx := context.Background()
cancelCtx, cancelFunc := context.WithCancel(ctx)
go task(cancelCtx)
time.Sleep(time.Second * 3)
cancelFunc() // 取消 context
time.Sleep(time.Second * 1) // 延時(shí)等待協(xié)程退出
fmt.Println("number of goroutine: ",runtime.NumGoroutine()) // 協(xié)程數(shù)量
}
func task(ctx context.Context) {
i := 1
for {
select {
case <-ctx.Done(): // 接收取消信號(hào)
fmt.Println("Gracefully exit")
fmt.Println(ctx.Err()) // 取消原因
return
default:
fmt.Println(i)
time.Sleep(time.Second * 1)
i++
}
}
}
輸出:
1
2
3
Gracefully exit
context canceled
number of goroutine: 1
當(dāng)調(diào)用 cancelFunc(),Done() 返回的 channel 變成可讀,Err() 返回取消原因 “context canceled”,task() 函數(shù)執(zhí)行 return 優(yōu)雅地退出。
context.WithValue()
通過(guò) context.WithValue() 可以在 goroutine 之間傳遞一些數(shù)據(jù)。
func main() {
helloWorldHandler := http.HandlerFunc(HelloWorld)
http.Handle("/hello", inejctRequestId(helloWorldHandler))
http.ListenAndServe(":8080", nil)
}
func HelloWorld(w http.ResponseWriter, r *http.Request) {
requestId := ""
if m := r.Context().Value("requestId"); m != nil {
if value, ok := m.(string); ok {
requestId = value
}
}
w.Header().Add("requestId", requestId)
w.Write([]byte("Hello, world"))
}
func inejctRequestId(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestId := uuid.New().String()
ctx := context.WithValue(r.Context(), "requestId", requestId)
req := r.WithContext(ctx)
next.ServeHTTP(w, req)
})
}
上面的代碼,inejctRequestId() 是請(qǐng)求中間函數(shù),通過(guò) context.WithValue() 注入了鍵值對(duì);HelloWorld() 是請(qǐng)求處理函數(shù),從 context 獲取到剛才綁定的 k-v。
go run 上面示例,然后執(zhí)行:
curl -v localhost:8080/hello
會(huì)輸出:
* Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /hello HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200 OK
< Requestid: e0b0544d-7993-4ff5-a2de-b29eacd3645a
< Date: Mon, 08 Feb 2021 13:13:06 GMT
< Content-Length: 12
< Content-Type: text/plain; charset=utf-8
<
* Connection #0 to host localhost left intact
Hello, world
從輸出可以看到,返回里有 Requestid 信息。
context.WithTimeout()
context.WithTimeout() 可以設(shè)置一個(gè)超時(shí)時(shí)間,過(guò)期之后 channel done 會(huì)自動(dòng)關(guān)閉,context 會(huì)被取消;超時(shí)之前可以調(diào)用取消函數(shù)手動(dòng)取消 context。
func main() {
ctx := context.Background()
cancelCtx, cancel := context.WithTimeout(ctx, time.Second*3)
defer cancel()
go task(cancelCtx)
time.Sleep(time.Second * 4)
}
func task(ctx context.Context) {
i := 1
for {
select {
case <-ctx.Done():
fmt.Println("Gracefully exit")
fmt.Println(ctx.Err())
return
default:
fmt.Println(i)
time.Sleep(time.Second * 1)
i++
}
}
}
輸出:
1
2
3
Gracefully exit
context deadline exceeded
上面的代碼,context.WithTimeout() 設(shè)置了 3s 的超時(shí)時(shí)間,時(shí)間到了之后,context 自動(dòng)取消,done channel 變成可讀,Err() 返回取消原因,執(zhí)行 return,task() 優(yōu)雅地退出。
context.WithDeadline()
context.WithDeadline() 設(shè)置一個(gè)將來(lái)的時(shí)間點(diǎn)作為截止時(shí)間,時(shí)間到了之后,channel done 會(huì)自動(dòng)關(guān)閉,context 會(huì)被取消;還未到截止時(shí)間可以調(diào)用取消函數(shù)手動(dòng)取消 context。
func main() {
ctx := context.Background()
cancelCtx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Second*3))
defer cancel()
go task(cancelCtx)
time.Sleep(time.Second * 4) // 延時(shí),等待 task() 正常退出
}
func task(ctx context.Context) {
i := 1
for {
select {
case <-ctx.Done():
fmt.Println("Gracefully exit")
fmt.Println(ctx.Err())
return
default:
fmt.Println(i)
time.Sleep(time.Second * 1)
i++
}
}
}
上面的代碼設(shè)置的截止時(shí)間是 3s 鐘之后的時(shí)間點(diǎn),時(shí)間到了之后,context 自動(dòng)取消,done channel 變成可讀,Err() 返回取消原因,執(zhí)行 return,task() 優(yōu)雅地退出。
輸出:
1
2
3
Gracefully exit
context deadline exceeded
相信你也能猜想到,其實(shí) context.WithTimeout() 底層是通過(guò) context.WithDeadline() 實(shí)現(xiàn)的,源碼如下:
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
ps: 看完示例之后,建議你回頭看看上面幾節(jié),相信對(duì)上面的內(nèi)容理解會(huì)更加深刻。
總結(jié)
全文已經(jīng)很好地回答了文章開始的四個(gè)問題,作為 Go 語(yǔ)言的核心功能之一,context 包已經(jīng)得到廣泛的應(yīng)用,比如上面例子里提到的 http 包。在使用時(shí)有幾個(gè)需要注意的地方:
context 是線程安全的,可在多個(gè) goroutine 中傳遞; 使用 context 作為函數(shù)參數(shù)時(shí),需作為第一個(gè)參數(shù),并且命名為 ctx; 不要把 context 放在結(jié)構(gòu)體中,要以參數(shù)的方式傳遞; 當(dāng)不知道傳遞什么類型 context 時(shí),可以使用 context.TODO(); context 只能被取消一次,應(yīng)當(dāng)避免從已取消的 context 衍生 context; 只有父 context 和創(chuàng)建了該 context 的函數(shù)才能調(diào)用取消函數(shù),避免傳遞取消函數(shù) cancelFunc;
參考資料
《Go 語(yǔ)言實(shí)戰(zhàn)筆記(二十)| Go Context》: https://www.flysnow.org/2017/05/12/go-in-action-go-context.html
推薦閱讀
