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

context tree
如上圖所示,是一個(gè)多叉樹(shù)。如果 root 調(diào)用 cancel 函數(shù)那么所有 children 也都會(huì)級(jí)聯(lián) cancel, 因?yàn)楸4?children 的是一個(gè) map, 也就無(wú)所謂先序中序后序了。如果 ctx 1-1 cancel, 那么他的 children 都會(huì) cancel, 但是 root 與 ctx 1-2 則不會(huì)受影響。
業(yè)務(wù)代碼當(dāng)調(diào)用棧比較深時(shí),就會(huì)出現(xiàn)這個(gè)多叉樹(shù)的形狀,另外 http 庫(kù)己經(jīng)集成了 context, 每個(gè) endpoint 的請(qǐng)求自帶一個(gè)從 http 庫(kù)派生出來(lái)的 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)
}
}
可以通過(guò) cancelCtx 的 cancel 看到原理,級(jí)聯(lián) cancel 所有 children
場(chǎng)景
來(lái)看一下使用場(chǎng)景吧,以一個(gè)標(biāo)準(zhǔn)的 watch etcd 來(lái)入手
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ù)傳進(jìn)來(lái)的 parent ctx 生成了 child ctx 與 cancel 函數(shù)。然后 Watch 時(shí)傳入 child ctx, 如果此時(shí) parent ctx 被外層 cancel 的話(huà),child ctx 也會(huì)被 cancel, rch 會(huì)被 etcd clientv3 關(guān)閉,然后 for 循環(huán)走到 select 邏輯,此時(shí) child ctx 被取消了,所以 <-ctx.Done() 生效,watch 函數(shù)返回。
其于 context 可以很好的做到多個(gè) goroutine 協(xié)作,超時(shí)管理,大大簡(jiǎn)化了開(kāi)發(fā)工作。
Bad Cases
那我們看幾個(gè)錯(cuò)誤使用 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 同時(shí)引用了 parent, 而 propagateCancel 函數(shù)的存在,parent 也會(huì)引用 child(當(dāng) parent 是 cancelCtx 類(lèi)型時(shí)).
如果此時(shí)打印 ctx, 就會(huì)遞歸調(diào)用 String() 方法,就會(huì)把 key/value 打印出來(lái)。如果此時(shí) value 是非線(xiàn)程安全的,比如 map, 就會(huì)引發(fā) concurrent read and write panic.
這個(gè)案例就是 http 標(biāo)準(zhǔn)庫(kù)的實(shí)現(xiàn) server.go:2906[2] 行代碼,把 http server 保存到 ctx 中
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
最后調(diào)用業(yè)務(wù)層代碼時(shí)把 ctx 傳給了用戶(hù)
go c.serve(connCtx)
如果此時(shí)打印 ctx, 就會(huì)打印 http srv 結(jié)構(gòu)體,這里面就有 map. 感興趣的可以做個(gè)實(shí)驗(yàn),拿 ab 壓測(cè)很容易復(fù)現(xiàn)。
2. 提前超時(shí)
func test(){
ctx, cancel := context.WithCancel(ctx)
defer cancel()
doSomething(ctx)
}
func doSomething(ctx){
go doOthers(ctx)
}
當(dāng)調(diào)用棧較深,多人合作時(shí)很容易產(chǎn)生這種情況。其實(shí)還是沒(méi)明白 ctx cancel 工作原理,異步 go 出去的業(yè)務(wù)邏輯需要基于 context.Background() 再派生 child ctx, 否則就會(huì)提前超時(shí)返回
3. 自定義 ctx
理論上沒(méi)必要自定義 ctx, 相比官方實(shí)現(xiàn),自定義有個(gè)很大的開(kāi)銷(xiāo)在于 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
}
通過(guò)源碼可知,parent 引用 child 有兩種方式,官方 cancelCtx 類(lèi)型的是用 map 保存。但是非官方的需要開(kāi)啟 goroutine 去監(jiān)測(cè)。本來(lái)業(yè)務(wù)代碼己經(jīng) goroutine 滿(mǎn)天飛了,不加節(jié)制的使用只會(huì)增加系統(tǒng)負(fù)擔(dān)。
另外聽(tīng)說(shuō)某大公司嫌棄這個(gè) map, 想要使用數(shù)組重寫(xiě)一版:(
原則
最后來(lái)總結(jié)下 context 使用的幾個(gè)原則:
除了框架層不要使用 WithValue攜帶業(yè)務(wù)數(shù)據(jù),這個(gè)類(lèi)型是interface{}, 編譯期無(wú)法確定,運(yùn)行時(shí) assert 有開(kāi)銷(xiāo)。如果真要攜帶也要用 thread-safe 的數(shù)據(jù)一定不要打印 context, 尤其是從 http 標(biāo)準(zhǔn)庫(kù)派生出來(lái)的,誰(shuí)知道里面存了什么context做為第一個(gè)參數(shù)傳給函數(shù),而不是當(dāng)成結(jié)構(gòu)體的成員字段來(lái)使用(雖然 etcd 代碼也這么用)盡可能不要自定義用戶(hù)層 context,除非收益巨大異步 goroutine 邏輯使用 context時(shí)要清楚誰(shuí)還持有,會(huì)不會(huì)提前超時(shí)派生出來(lái)的 child ctx一定要配合defer cancel()使用,釋放資源
小結(jié)
這次分享就這些,以后面還會(huì)分享更多的內(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,
推薦閱讀
