也許是 Go Context 最佳實踐
最早 context 是獨立的第三方庫,后來才移到標(biāo)準(zhǔn)庫里。關(guān)于這個庫該不該用有很多爭義,比如 Context should go away for Go 2[1]. 不管爭義多大,本著務(wù)實的哲學(xué),所有的開源項目都重度使用,當(dāng)然也包括業(yè)務(wù)代碼。
但是我發(fā)現(xiàn)并不是每個人都了解 context, 從去年到現(xiàn)在就見過兩次因為錯誤使用導(dǎo)致的問題。每個同學(xué)都會踩到坑,今天分享下 context 庫使用的 Dos and Don'ts
原理
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Context 是一個接口
Deadlinectx 如果在某個時間點關(guān)閉的話,返回該值。否則 ok 為 falseDone返回一個 channel, 如果超時或是取消就會被關(guān)閉,實現(xiàn)消息通訊Err如果當(dāng)前 ctx 超時或被取消了,那么Err返回錯誤Value根據(jù)某個 key 返回對應(yīng)的 value, 功能類似字典
目前的實現(xiàn)有 emptyCtx, valueCtx, cancelCtx, timerCtx. 可以基于某個 Parent 派生成 Child Context
func WithValue(parent Context, key, val interface{}) Context
func WithCancel(parent Context) (Context, CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
這是四個常用的派生函數(shù),WithValue 包裝 key/value 返回 valueCtx, 后三個返回兩個值 Context 是 child ctx, CancelFunc 是取消該 ctx 的函數(shù)?;谶@個特性呢,經(jīng)過多次派生,context 是一個樹形結(jié)構(gòu)

context tree
如上圖所示,是一個多叉樹。如果 root 調(diào)用 cancel 函數(shù)那么所有 children 也都會級聯(lián) cancel, 因為保存 children 的是一個 map, 也就無所謂先序中序后序了。如果 ctx 1-1 cancel, 那么他的 children 都會 cancel, 但是 root 與 ctx 1-2 則不會受影響。
業(yè)務(wù)代碼當(dāng)調(diào)用棧比較深時,就會出現(xiàn)這個多叉樹的形狀,另外 http 庫己經(jīng)集成了 context, 每個 endpoint 的請求自帶一個從 http 庫派生出來的 child
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // already canceled
}
c.err = err
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
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()
if removeFromParent {
removeChild(c.Context, c)
}
}
可以通過 cancelCtx 的 cancel 看到原理,級聯(lián) cancel 所有 children
場景
來看一下使用場景吧,以一個標(biāo)準(zhǔn)的 watch etcd 來入手
func watch(ctx context.Context, revision int64) {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
for {
rch := watcher.Watch(ctx, watchPath, clientv3.WithRev(revision))
for wresp := range rch {
......
doSomething()
}
select {
case <-ctx.Done():
// server closed, return
return
default:
}
}
}
首先基于參數(shù)傳進來的 parent ctx 生成了 child ctx 與 cancel 函數(shù)。然后 Watch 時傳入 child ctx, 如果此時 parent ctx 被外層 cancel 的話,child ctx 也會被 cancel, rch 會被 etcd clientv3 關(guān)閉,然后 for 循環(huán)走到 select 邏輯,此時 child ctx 被取消了,所以 <-ctx.Done() 生效,watch 函數(shù)返回。
其于 context 可以很好的做到多個 goroutine 協(xié)作,超時管理,大大簡化了開發(fā)工作。
Bad Cases
那我們看幾個錯誤使用 context 的案例,都非常經(jīng)典
1. 打印 ctx
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}
// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
}
以 WithCancel 為例子,可以看到 child 同時引用了 parent, 而 propagateCancel 函數(shù)的存在,parent 也會引用 child(當(dāng) parent 是 cancelCtx 類型時).
如果此時打印 ctx, 就會遞歸調(diào)用 String() 方法,就會把 key/value 打印出來。如果此時 value 是非線程安全的,比如 map, 就會引發(fā) concurrent read and write panic.
這個案例就是 http 標(biāo)準(zhǔn)庫的實現(xiàn) server.go:2906[2] 行代碼,把 http server 保存到 ctx 中
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
最后調(diào)用業(yè)務(wù)層代碼時把 ctx 傳給了用戶
go c.serve(connCtx)
如果此時打印 ctx, 就會打印 http srv 結(jié)構(gòu)體,這里面就有 map. 感興趣的可以做個實驗,拿 ab 壓測很容易復(fù)現(xiàn)。
2. 提前超時
func test(){
ctx, cancel := context.WithCancel(ctx)
defer cancel()
doSomething(ctx)
}
func doSomething(ctx){
go doOthers(ctx)
}
當(dāng)調(diào)用棧較深,多人合作時很容易產(chǎn)生這種情況。其實還是沒明白 ctx cancel 工作原理,異步 go 出去的業(yè)務(wù)邏輯需要基于 context.Background() 再派生 child ctx, 否則就會提前超時返回
3. 自定義 ctx
理論上沒必要自定義 ctx, 相比官方實現(xiàn),自定義有個很大的開銷在于 child 如何響應(yīng) parent cancel
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
......
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
......
} else {
......
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
atomic.AddInt32(&goroutines, +1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
......
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
if !ok {
return nil, false
}
......
return p, true
}
通過源碼可知,parent 引用 child 有兩種方式,官方 cancelCtx 類型的是用 map 保存。但是非官方的需要開啟 goroutine 去監(jiān)測。本來業(yè)務(wù)代碼己經(jīng) goroutine 滿天飛了,不加節(jié)制的使用只會增加系統(tǒng)負擔(dān)。
另外聽說某大公司嫌棄這個 map, 想要使用數(shù)組重寫一版:(
原則
最后來總結(jié)下 context 使用的幾個原則:
除了框架層不要使用 WithValue攜帶業(yè)務(wù)數(shù)據(jù),這個類型是interface{}, 編譯期無法確定,運行時 assert 有開銷。如果真要攜帶也要用 thread-safe 的數(shù)據(jù)一定不要打印 context, 尤其是從 http 標(biāo)準(zhǔn)庫派生出來的,誰知道里面存了什么context做為第一個參數(shù)傳給函數(shù),而不是當(dāng)成結(jié)構(gòu)體的成員字段來使用(雖然 etcd 代碼也這么用)盡可能不要自定義用戶層 context,除非收益巨大異步 goroutine 邏輯使用 context時要清楚誰還持有,會不會提前超時派生出來的 child ctx一定要配合defer cancel()使用,釋放資源
小結(jié)
這次分享就這些,以后面還會分享更多的內(nèi)容,如果感興趣,可以關(guān)注并轉(zhuǎn)發(fā)(:
參考資料
Context should go away for Go 2: https://faiface.github.io/post/context-should-go-away-go2/,
[2]server.go: https://github.com/golang/go/blob/master/src/net/http/server.go#L2878,
推薦閱讀
