Golang Context 的原理與實戰
阿新 • • 發佈:2020-05-03
本文讓我們一起來學習 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