1. 程式人生 > >Golang Context 的原理與實戰

Golang Context 的原理與實戰

本文讓我們一起來學習 golang Context 的使用和標準庫中的Context的實現。 golang context 包 一開始只是 Google 內部使用的一個 Golang 包,在 Golang 1.7的版本中正式被引入標準庫。下面開始學習。 ## 簡單介紹 在學習 context 包之前,先看幾種日常開發中經常會碰到的業務場景: 1. 業務需要對訪問的資料庫,RPC ,或API介面,為了防止這些依賴導致我們的服務超時,需要針對性的做超時控制。 2. 為了詳細瞭解服務效能,記錄詳細的呼叫鏈Log。 上面兩種場景在web中是比較常見的,context 包就是為了方便我們應對此類場景而使用的。 接下來, 我們首先學習 context 包有哪些方法供我們使用;接著舉一些例子,使用 context 包應用在我們上述場景中去解決我們遇到的問題;最後從原始碼角度學習 context 內部實現,瞭解 context 的實現原理。 ## Context 包 ### Context 定義 context 包中實現了多種 Context 物件。Context 是一個介面,用來描述一個程式的上下文。介面中提供了四個抽象的方法,定義如下: ```golang 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 提供了兩個方法做初始化: ```golang func Background() Context{} func TODO() Context {} ``` 上面方法均會返回空的 Context,但是 Background 一般是所有 Context 的基礎,所有 Context 的源頭都應該是它。TODO 方法一般用於當傳入的方法不確定是哪種型別的 Context 時,為了避免 Context 的引數為nil而初始化的 Context。 其他的 Context 都是基於已經構造好的 Context 來實現的。一個 Context 可以派生多個子 context。基於 Context 派生新Context 的方法如下: ```golang 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。 ```golang func WithValue(parent Context, key, val interface{}) Context {} ``` withValue 會構造一個新的context,新的context 會包含一對 Key-Value 資料,可以通過Context.Value(Key) 獲取存在 ctx 中的 Value 值。 通過上面的理解可以直到,Context 是一個樹狀結構,一個 Context 可以派生出多個不一樣的Context。我們大概可以畫一個如下的樹狀圖: ![](https://img2020.cnblogs.com/blog/527714/202005/527714-20200503102547319-1286257210.jpg) 一個background,衍生出一個帶有traceId的valueCtx,然後valueCtx衍生出一個帶有cancelCtx 的context。最終在一些db查詢,http查詢,rpc沙遜等非同步呼叫中體現。如果出現超時,直接把這些非同步呼叫取消,減少消耗的資源,我們也可以在呼叫時,通過Value 方法拿到traceId,並記錄下對應請求的資料。 當然,除了上面的幾種 Context 外,我們也可以基於上述的 Context 介面實現新的Context. ## 使用方法 下面我們舉幾個例子,學習上面講到的方法。 ### 超時查詢的例子 在做資料庫查詢時,需要對資料的查詢做超時控制,例如: ```golang 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的連結,程式碼大概如下: ```golang // 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 等操作時,也會有類似方法: ```golang select { default: // 校驗是否已經超時,如果超時直接返回 case <-ctx.Done(): return nil, ctx.Err() } // 如果還沒有超時,呼叫驅動做查詢 return queryer.Query(query, dargs) ``` 上面在做查詢時,首先判斷是否已經超時了,如果超時,則直接返回錯誤,否則才進行查詢。 可以看出,在派生出的帶有超時取消功能的 Context 時,內部方法在做非同步操作(比如獲取連結,查詢等)時會先檢視是否已經 Done了,如果Done,說明請求已超時,直接返回錯誤;否則繼續等待,或者做下一步工作。這裡也可以看出,要做到超時控制,需要不斷判斷 Done() 是否已關閉。 ### 鏈路追蹤的例子 在做鏈路追蹤時,Context 也是非常重要的。(所謂鏈路追蹤,是說可以追蹤某一個請求所依賴的模組,比如db,redis,rpc下游,介面下游等服務,從這些依賴服務中找到請求中的時間消耗) 下面舉一個鏈路追蹤的例子: ```golang // 建議把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例項,程式碼如下。 ```golang type cancelCtx struct { Context // 互斥鎖,保證context協程安全 mu sync.Mutex // cancel 的時候,close 這個chan done chan struct{} // 派生的context children map[canceler]struct{} err error } ``` WithCancel 方法首先會基於 parent 構建一個新的 Context,程式碼如下: ```golang 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 的實現: ```golang // 把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 變數是延時初始化的。 ```golang 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 方法,程式碼如下: ```golang 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 結構體: ```golang type timerCtx struct { cancelCtx // 計時器 timer *time.Timer // 截止時間 deadline time.Time } ``` WithDeadline 的實現: ```golang 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 的基礎上,做了關閉定時器的操作 ```golang 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 原型如下: ```golang type valueCtx struct { // 保留了父節點的context Context key, val interface{} } ``` 每個context 包含了一個Key-Value組合。valueCtx 保留了父節點的Context,但沒有像cancelCtx 一樣保留子節點的Context. 下面是valueCtx的構造方法: ```golang 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 的方法: ```golang 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://img2020.cnblogs.com/blog/527714/202005/527714-20200503102514730-15262970