走進Golang之Context的使用
我們?yōu)槭裁葱枰?Context 的呢?我們來看看看一個 HTTP 請求的處理:

例子大概意思是說,有一個獲取訂單詳情的請求,會單獨起一個 goroutine 去處理該請求。在該請求內部又有三個分支 goroutine 分別處理訂單詳情、推薦商品、物流信息;每個分支可能又需要單獨調用DB、Redis等存儲組件。那么面對這個場景我們需要哪些額外的事情呢?
三個分支 goroutine 可能是對應的三個不同服務,我們想要攜帶一些基礎信息過去,比如:LogID、UserID、IP等; 每個分支我們需要設置過期時間,如果某個超時不影響整個流程; 如果主 goroutine 發(fā)生錯誤,取消了請求,對應的三個分支應該也都取消,避免資源浪費;
簡單歸納就是傳值、同步信號(取消、超時)。
看到這里可能有人要叫了,完全可以用 channel 來搞?。∧敲次覀兛纯?channel 是否可以滿足。想一個問題,如果是微服務架構,channel 怎么實現(xiàn)跨進程的邊界呢?另外一個問題,就算不跨進程,如果嵌套很多個分支,想一想這個消息傳遞的復雜度。
如果是你,要實現(xiàn)上面的這個需求,你會怎么做?
Context 出場
幸好,我們不用自己每次寫代碼都要去實現(xiàn)這個很基礎的能力。Golang 為我們準備好了一切,就是 context.Context 這個包,這個包的源代碼非常簡單,源碼部分本文會略過,下期單獨一篇文章來講,本篇我們重點談正確的使用。
Context 的結構非常簡單,它是一個接口。
// Context 提供跨越API的截止時間獲取,取消信號,以及請求范圍值的功能。
//?它的這些方案在多個?goroutine?中使用是安全的
type?Context?interface?{
????//?如果設置了截止時間,這個方法ok會是true,并返回設置的截止時間
?Deadline()?(deadline?time.Time,?ok?bool)
????//?如果?Context?超時或者主動取消返回一個關閉的channel,如果返回的是nil,表示這個
????// context 永遠不會關閉,比如:Background()
?Done()?<-chan?struct{}
????//?返回發(fā)生的錯誤
?Err()?error
????//?它的作用就是傳值
?Value(key?interface{})?interface{}
}
寫到這里,我們打住想一想,如果你來實現(xiàn)這樣一個能力的 package,你抽象的接口是否也是具備這樣四個能力?
獲取截止時間 獲取信號 獲取信號產(chǎn)生的對應錯誤信息 傳值專用
net/http 中是怎么用 context的?
在我們開始自己鼓搗前,我們先看看 net/http 這個包是怎么使用的。
func?main()?{
?req,?_?:=?http.NewRequest("GET",?"https://api.github.com/users/helei112g",?nil)
?//?這里設置了超時時間
?ctx,?cancel?:=?context.WithTimeout(context.Background(),?time.Millisecond*1)
?defer?cancel()
?req?=?req.WithContext(ctx)
?resp,?err?:=?http.DefaultClient.Do(req)
?if?err?!=?nil?{
??log.Fatalln("request?Err",?err.Error())
?}
?defer?resp.Body.Close()
?body,?_?:=?ioutil.ReadAll(resp.Body)
?fmt.Println(string(body))
}
上面這段程序就是請求 github 獲取用戶信息的接口,通過 context 包設置了請求超時時間是 1ms (肯定無法訪問到)。執(zhí)行時我們看到控制臺做如下輸出:
2020/xx/xx xx:xx:xx request Err Get https://api.github.com/users/helei112g: context deadline exceeded
exit status 1
我們繼續(xù)做實驗,將上面的代碼稍作修改。
func?main()?{
????req,?_?:=?http.NewRequest("GET",?"https://api.github.com/users/helei112g",?nil)
????//?這里超時改成了?10s,怎么都夠了吧
????ctx,?cancel?:=?context.WithTimeout(context.Background(),?time.Second*10)
????//?但是這里移出了?defer?關鍵字
? cancel()
? req?=?req.WithContext(ctx)
????//?沒有改動的部分,省略
????...?...
}
大家猜猜看能否獲取到請求結果?肯定是不能的,因為 context 取消的信號,在 net/http 包內部通過 ctx.Done() 是能夠拿到的,一旦獲取到就會進行取消。上面的代碼,控制臺會輸出:
2020/xx/xx xx:xx:xx request Err Get https://api.github.com/users/helei112g: context canceled
exit status 1
注意兩次控制臺輸出的錯誤信息是不一樣的。
context deadline exceeded 表示執(zhí)行超時被取消了 context canceled 表示主動取消
net/http 中 context 獲取取消信號
接下來,我們去看看 net/http 包內部是怎么捕捉信號的,我們只關注 context 的部分,其它的直接忽略,源碼路徑如下;
net/http/transport.go (go 1.13.7)
//?req?就是我們上面?zhèn)鬟M來的?req,它有個?context?字段
func?(t?*Transport)?roundTrip(req?*Request)?(*Response,?error)?{
?t.nextProtoOnce.Do(t.onceSetNextProtoDefaults)
?ctx?:=?req.Context()?//?獲取了?context
?trace?:=?httptrace.ContextClientTrace(ctx)?//?這里內部實際用到了?context.Value()?方法
?//?各種處理,無關代碼刪除了
?//?處理請求
?for?{
??//?檢查是否關閉了,如果關閉了就直接返回
??select?{
??case?<-ctx.Done():
???req.closeBody()
???return?nil,?ctx.Err()
??default:
??}
??//?發(fā)送請求出去
?}
}
來總結下上面這段代碼,實際上關于 context 的精髓就在 for 循環(huán)中的 select,它通過 ctx.Done() 來獲取信號,因為不管是自動超時,還是主動取消,ctx.Done() 都會收到一個關閉的 channel 的信號。
這里隱藏了一個細節(jié),那就是如果按照上面的邏輯只能處理到發(fā)起請求前的超時,但是如果請求已經(jīng)被發(fā)出去了,等待這段時間的超時該如何控制呢?感興趣的小伙伴可以去看源碼的這里:
net/http/transport.go:1234 (go 1.13.7)
其實就是在內部等待返回的時候不斷的檢查 ctx.Done() 信號,如果發(fā)現(xiàn)了就立即返回。
好了,官方的技巧我們已經(jīng)學完了,現(xiàn)在輪到我們把開頭的例子寫個代碼來實現(xiàn)下。
多個 goroutine 控制超時及傳值
由于服務內部不方便模擬,我們簡化成函數(shù)調用,假設圖中所有的邏輯都可以并發(fā)調用?,F(xiàn)在我們的要求是:
整個函數(shù)的超時時間為1s; 需要從最外層傳遞 LogID/UserID/IP 信息到其它函數(shù); 獲取訂單接口超時為 500ms,由于 DB/Redis 是其內部支持的,這里不進行模擬; 獲取推薦超時是 400ms; 獲取物流超時是 700ms。
為了清晰,我這里所有接口都返回一個字符串,實際中會根據(jù)需要返回不同的結果;請求參數(shù)也都只使用了 context。代碼如下:
type?key?int
const?(
?userIP?=?iota
?userID
?logID
)
type?Result?struct?{
?order?????string
?logistics?string
?recommend?string
}
//?timeout:?1s
//?入口函數(shù)
func?api()?(result?*Result,?err?error)?{
?ctx,?cancel?:=?context.WithTimeout(context.Background(),?time.Second*1)
?defer?cancel()
?//?設置值
?ctx?=?context.WithValue(ctx,?userIP,?"127.0.0.1")
?ctx?=?context.WithValue(ctx,?userID,?666888)
?ctx?=?context.WithValue(ctx,?logID,?"123456")
?result?=?&Result{}
?//?業(yè)務邏輯處理放到協(xié)程中
?go?func()?{
??result.order,?err?=?getOrderDetail(ctx)
?}()
?go?func()?{
??result.logistics,?err?=?getLogisticsDetail(ctx)
?}()
?go?func()?{
??result.recommend,?err?=?getRecommend(ctx)
?}()
?for?{
??select?{
??case?<-ctx.Done():
???return?result,?ctx.Err()?//?取消或者超時,把現(xiàn)有已經(jīng)拿到的結果返回
??default:
??}
??//?有錯誤直接返回
??if?err?!=?nil?{
???return?result,?err
??}
??//?全部處理完成,直接返回
??if?result.order?!=?""?&&?result.logistics?!=?""?&&?result.recommend?!=?""?{
???return?result,?nil
??}
?}
}
//?timeout:?500ms
func?getOrderDetail(ctx?context.Context)?(string,?error)?{
?ctx,?cancel?:=?context.WithTimeout(ctx,?time.Millisecond*500)
?defer?cancel()
?//?模擬超時
?time.Sleep(time.Millisecond?*?700)
?//?獲取?user?id
?uip?:=?ctx.Value(userIP).(string)
?fmt.Println("userIP",?uip)
?return?handleTimeout(ctx,?func()?string?{
??return?"order"
?})
}
//?timeout:?700ms
func?getLogisticsDetail(ctx?context.Context)?(string,?error)?{
?ctx,?cancel?:=?context.WithTimeout(ctx,?time.Millisecond*700)
?defer?cancel()
?//?獲取?user?id
?uid?:=?ctx.Value(userID).(int)
?fmt.Println("userID",?uid)
?return?handleTimeout(ctx,?func()?string?{
??return?"logistics"
?})
}
//?timeout:?400ms
func?getRecommend(ctx?context.Context)?(string,?error)?{
?ctx,?cancel?:=?context.WithTimeout(ctx,?time.Millisecond*400)
?defer?cancel()
?//?獲取?log?id
?lid?:=?ctx.Value(logID).(string)
?fmt.Println("logID",?lid)
?return?handleTimeout(ctx,?func()?string?{
??return?"recommend"
?})
}
//?超時的統(tǒng)一處理代碼
func?handleTimeout(ctx?context.Context,?f?func()?string)?(string,?error)?{
?//?請求之前先去檢查下是否超時
?select?{
?case?<-ctx.Done():
??return?"",?ctx.Err()
?default:
?}
?str?:=?make(chan?string)
?go?func()?{
??//?業(yè)務邏輯
??str?<-?f()
?}()
?select?{
?case?<-ctx.Done():
??return?"",?ctx.Err()
?case?ret?:=?<-str:
??return?ret,?nil
?}
}
不知道你是否看明白了整個使用,我們這個例子看起來很復雜,實際上與我給你介紹的 net/http 包控制超時是一樣的,只不過 net/http 的控制超時代碼不需要我們寫,而且我們這里一次性把三個調用的整合到了一起。
還有一點說明一下,對于 select,如果沒有寫 defalut 分支,是不需要放在 for 循環(huán)中的,因為它本身就會阻塞(網(wǎng)絡上有很多例子放在for循環(huán)中)。
參考資料
[1] Package context [2] Go Concurrency Patterns: Context
推薦閱讀
站長 polarisxu
自己的原創(chuàng)文章
不限于 Go 技術
職場和創(chuàng)業(yè)經(jīng)驗
Go語言中文網(wǎng)
每天為你
分享 Go 知識
Go愛好者值得關注
