本文讓我們一起來學習 golang Context 的使用和標準庫中的Context的實作,
golang context 包 一開始只是 Google 內部使用的一個 Golang 包,在 Golang 1.7的版本中正式被引入標準庫,下面開始學習,
簡單介紹
在學習 context 包之前,先看幾種日常開發中經常會碰到的業務場景:
- 業務需要對訪問的資料庫,RPC ,或API介面,為了防止這些依賴導致我們的服務超時,需要針對性的做超時控制,
- 為了詳細了解服務性能,記錄詳細的呼叫鏈Log,
上面兩種場景在web中是比較常見的,context 包就是為了方便我們應對此類場景而使用的,
接下來, 我們首先學習 context 包有哪些方法供我們使用;接著舉一些例子,使用 context 包應用在我們上述場景中去解決我們遇到的問題;最后從原始碼角度學習 context 內部實作,了解 context 的實作原理,
Context 包
Context 定義
context 包中實作了多種 Context 物件,Context 是一個介面,用來描述一個程式的背景關系,介面中提供了四個抽象的方法,定義如下:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
- Deadline() 回傳的是背景關系的截至時間,如果沒有設定,ok 為 false
- Done() 當執行的背景關系被取消后,Done回傳的chan就會被close,如果這個背景關系不會被取消,回傳nil
- Err() 有幾種情況:
- 如果Done() 回傳 chan 沒有關閉,回傳nil
- 如果Done() 回傳的chan 關閉了, Err 回傳一個非nil的值,解釋為什么會Done()
- 如果Canceled,回傳 "Canceled"
- 如果超過了 Deadline,回傳 "DeadlineEsceeded"
- Value(key) 回傳背景關系中 key 對應的 value 值
Context 構造
為了使用 Context,我們需要了解 Context 是怎么構造的,
Context 提供了兩個方法做初始化:
func Background() Context{}
func TODO() Context {}
上面方法均會回傳空的 Context,但是 Background 一般是所有 Context 的基礎,所有 Context 的源頭都應該是它,TODO 方法一般用于當傳入的方法不確定是哪種型別的 Context 時,為了避免 Context 的引數為nil而初始化的 Context,
其他的 Context 都是基于已經構造好的 Context 來實作的,一個 Context 可以派生多個子 context,基于 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) {}
上面三種方法比較類似,均會基于 parent Context 生成一個子 ctx,以及一個 Cancel 方法,如果呼叫了cancel 方法,ctx 以及基于 ctx 構造的子 context 都會被取消,不同點在于 WithCancel 必需要手動呼叫 cancel 方法,WithDeadline
可以設定一個時間點,WithTimeout 是設定呼叫的持續時間,到指定時間后,會呼叫 cancel 做取消操作,
除了上面的構造方式,還有一類是用來創建傳遞 traceId, token 等重要資料的 Context,
func WithValue(parent Context, key, val interface{}) Context {}
withValue 會構造一個新的context,新的context 會包含一對 Key-Value 資料,可以通過Context.Value(Key) 獲取存在 ctx 中的 Value 值,
通過上面的理解可以直到,Context 是一個樹狀結構,一個 Context 可以派生出多個不一樣的Context,我們大概可以畫一個如下的樹狀圖:

一個background,衍生出一個帶有traceId的valueCtx,然后valueCtx衍生出一個帶有cancelCtx
的context,最終在一些db查詢,http查詢,rpc沙遜等異步呼叫中體現,如果出現超時,直接把這些異步呼叫取消,減少消耗的資源,我們也可以在呼叫時,通過Value 方法拿到traceId,并記錄下對應請求的資料,
當然,除了上面的幾種 Context 外,我們也可以基于上述的 Context 介面實作新的Context.
使用方法
下面我們舉幾個例子,學習上面講到的方法,
超時查詢的例子
在做資料庫查詢時,需要對資料的查詢做超時控制,例如:
ctx = context.WithTimeout(context.Background(), time.Second)
rows, err := pool.QueryContext(ctx, "select * from products where id = ?", 100)
上面的代碼基于 Background 派生出一個帶有超時取消功能的ctx,傳入帶有context查詢的方法中,如果超過1s未回傳結果,則取消本次的查詢,使用起來非常方便,為了了解查詢內部是如何做到超時取消的,我們看看DB內部是如何使用傳入的ctx的,
在查詢時,需要先從pool中獲取一個db的鏈接,代碼大概如下:
// src/database/sql/sql.go
// func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) *driverConn, error)
// 阻塞從req中獲取鏈接,如果超時,直接回傳
select {
case <-ctx.Done():
// 獲取鏈接超時了,直接回傳錯誤
// do something
return nil, ctx.Err()
case ret, ok := <-req:
// 拿到鏈接,校驗并回傳
return ret.conn, ret.err
}
req 也是一個chan,是等待鏈接回傳的chan,如果Done() 回傳的chan 關閉后,則不再關心req的回傳了,我們的查詢就超時了,
在做SQL Prepare、SQL Query 等操作時,也會有類似方法:
select {
default:
// 校驗是否已經超時,如果超時直接回傳
case <-ctx.Done():
return nil, ctx.Err()
}
// 如果還沒有超時,呼叫驅動做查詢
return queryer.Query(query, dargs)
上面在做查詢時,首先判斷是否已經超時了,如果超時,則直接回傳錯誤,否則才進行查詢,
可以看出,在派生出的帶有超時取消功能的 Context 時,內部方法在做異步操作(比如獲取鏈接,查詢等)時會先查看是否已經
Done了,如果Done,說明請求已超時,直接回傳錯誤;否則繼續等待,或者做下一步作業,這里也可以看出,要做到超時控制,需要不斷判斷 Done() 是否已關閉,
鏈路追蹤的例子
在做鏈路追蹤時,Context 也是非常重要的,(所謂鏈路追蹤,是說可以追蹤某一個請求所依賴的模塊,比如db,redis,rpc下游,介面下游等服務,從這些依賴服務中找到請求中的時間消耗)
下面舉一個鏈路追蹤的例子:
// 建議把key 型別不匯出,防止被覆寫
type traceIdKey struct{}{}
// 定義固定的Key
var TraceIdKey = traceIdKey{}
func ServeHTTP(w http.ResponseWriter, req *http.Request){
// 首先從請求中拿到traceId
// 可以把traceId 放在header里,也可以放在body中
// 還可以自己建立一個 (如果自己是請求源頭的話)
traceId := getTraceIdFromRequest(req)
// Key 存入 ctx 中
ctx := context.WithValue(req.Context(), TraceIdKey, traceId)
// 設定介面1s 超時
ctx = context.WithTimeout(ctx, time.Second)
// query RPC 時可以攜帶 traceId
repResp := RequestRPC(ctx, ...)
// query DB 時可以攜帶 traceId
dbResp := RequestDB(ctx, ...)
// ...
}
func RequestRPC(ctx context.Context, ...) interface{} {
// 獲取traceid,在呼叫rpc時記錄日志
traceId, _ := ctx.Value(TraceIdKey)
// request
// do log
return
}
上述代碼中,當拿到請求后,我們通過req 獲取traceId, 并記錄在ctx中,在呼叫RPC,DB等時,傳入我們構造的ctx,在后續代碼中,我們可以通過ctx拿到我們存入的traceId,使用traceId 記錄請求的日志,方便后續做問題定位,
當然,一般情況下,context 不會單純的僅僅是用于 traceId 的記錄,或者超時的控制,很有可能二者兼有之,
如何實作
知其然也需知其所以然,想要充分利用好 Context,我們還需要學習 Context 的實作,下面我們一起學習不同的 Context 是如何實作 Context 介面的,
空背景關系
Background(), Empty() 均會回傳一個空的 Context emptyCtx,emptyCtx 物件在方法 Deadline(), Done(), Err(), Value(interface{}) 中均會回傳nil,String() 方法會回傳對應的字串,這個實作比較簡單,我們這里暫時不討論,
有取消功能的背景關系
WithCancel 構造的context 是一個cancelCtx實體,代碼如下,
type cancelCtx struct {
Context
// 互斥鎖,保證context協程安全
mu sync.Mutex
// cancel 的時候,close 這個chan
done chan struct{}
// 派生的context
children map[canceler]struct{}
err error
}
WithCancel 方法首先會基于 parent 構建一個新的 Context,代碼如下:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent) // 新的背景關系
propagateCancel(parent, &c) // 掛到parent 上
return &c, func() { c.cancel(true, Canceled) }
}
其中,propagateCancel 方法會判斷 parent 是否已經取消,如果取消,則直接呼叫方法取消;如果沒有取消,會在parent的children 追加一個child,這里就可以看出,context 樹狀結構的實作, 下面是propateCancel 的實作:
// 把child 掛在到parent 下
func propagateCancel(parent Context, child canceler) {
// 如果parent 為空,則直接回傳
if parent.Done() == nil {
return // parent is never canceled
}
// 獲取parent型別
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
// parent has already been canceled
child.cancel(false, p.err)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 啟動goroutine,等待parent/child Done
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
Done() 實作比較簡單,就是回傳一個chan,等待chan 關閉,可以看出 Done 操作是在呼叫時才會構造 chan done,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
}
在手動取消 Context 時,會呼叫 cancelCtx 的 cancel 方法,代碼如下:
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
// 一些判斷,關閉 ctx.done chan
// ...
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
// 廣播到所有的child,需要cancel goroutine 了
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()
// 然后從父context 中,洗掉當前的context
if removeFromParent {
removeChild(c.Context, c)
}
}
這里可以看到,當執行cancel時,除了會關閉當前的cancel外,還做了兩件事,① 所有的child 都呼叫cancel方法,② 由于該背景關系已經關閉,需要從父背景關系中移除當前的背景關系,
定時取消功能的背景關系
WithDeadline, WithTimeout 提供了實作定時功能的 Context 方法,回傳一個timerCtx結構體,WithDeadline 是給定了執行截至時間,WithTimeout 是倒計時時間,WithTImeout 是基于WithDeadline實作的,因此我們僅看其中的WithDeadline
即可,WithDeadline 內部實作是基于cancelCtx 的,相對于 cancelCtx 增加了一個計時器,并記錄了 Deadline 時間點,下面是timerCtx 結構體:
type timerCtx struct {
cancelCtx
// 計時器
timer *time.Timer
// 截止時間
deadline time.Time
}
WithDeadline 的實作:
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
// 若父背景關系結束時間早于child,
// 則child直接掛載在parent背景關系下即可
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
return WithCancel(parent)
}
// 創建個timerCtx, 設定deadline
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
// 將context掛在parent 之下
propagateCancel(parent, c)
// 計算倒計時時間
dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded) // deadline has already passed
return c, func() { c.cancel(false, Canceled) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
// 設定一個計時器,到時呼叫cancel
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}
構造方法中,將新的context 掛在到parent下,并創建了倒計時器定期觸發cancel,
timerCtx 的cancel 操作,和cancelCtx 的cancel 操作是非常類似的,在cancelCtx 的基礎上,做了關閉定時器的操作
func (c *timerCtx) cancel(removeFromParent bool, err error) {
// 呼叫cancelCtx 的cancel 方法 關閉chan,并通知子context,
c.cancelCtx.cancel(false, err)
// 從parent 中移除
if removeFromParent {
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
// 關掉定時器
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}
timeCtx 的 Done 操作直接復用了cancelCtx 的 Done 操作,直接關閉 chan done 成員,
傳遞值的背景關系
WithValue 構造的背景關系與上面幾種有區別,其構造的context 原型如下:
type valueCtx struct {
// 保留了父節點的context
Context
key, val interface{}
}
每個context 包含了一個Key-Value組合,valueCtx 保留了父節點的Context,但沒有像cancelCtx 一樣保留子節點的Context. 下面是valueCtx的構造方法:
func WithValue(parent Context, key, val interface{}) Context {
if key == nil {
panic("nil key")
}
// key 必須是課比較的,不然無法獲取Value
if !reflect.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val}
}
直接將Key-Value賦值給struct 即可完成構造,下面是獲取Value 的方法:
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
// 從父context 中獲取
return c.Context.Value(key)
}
Value 的獲取是采用鏈式獲取的方法,如果當前 Context 中找不到,則從父Context中獲取,如果我們希望一個context 多放幾條資料時,可以保存一個map 資料到 context 中,這里不建議多次構造context來存放資料,畢竟取資料的成本也是比較高的,
注意事項
最后,在使用中應該注意如下幾點:
- context.Background 用在請求進來的時候,所有其他context 來源于它,
- 在傳入的conttext 不確定使用的是那種型別的時候,傳入TODO context (不應該傳入一個nil 的context)
- context.Value 不應該傳入可選的引數,應該是每個請求都一定會自帶的一些資料,(比如說traceId,授權token 之類的),在Value 使用時,建議把Key 定義為全域const 變數,并且key 的型別不可匯出,防止資料存在沖突,
- context goroutines 安全,

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/17403.html
標籤:Go
