也許是 Go Context 最佳實踐
以下文章來源於董澤潤的技術筆記 ,作者董澤潤
最早 context
是獨立的第三方庫,後來才移到標準庫裏。關於這個庫該不該用有很多爭義,比如 Context should go away for Go 2[1]. 不管爭義多大,本着務實的哲學,所有的開源項目都重度使用,當然也包括業務代碼。
但是我發現並不是每個人都瞭解 context
, 從去年到現在就見過兩次因爲錯誤使用導致的問題。每個同學都會踩到坑,今天分享下 context
庫使用的 Dos and Don'ts
原理
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Context
是一個接口
-
Deadline
ctx 如果在某個時間點關閉的話,返回該值。否則 ok 爲 false -
Done
返回一個 channel, 如果超時或是取消就會被關閉,實現消息通訊 -
Err
如果當前 ctx 超時或被取消了,那麼Err
返回錯誤 -
Value
根據某個 key 返回對應的 value, 功能類似字典
目前的實現有 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)
這是四個常用的派生函數,WithValue
包裝 key/value 返回 valueCtx
, 後三個返回兩個值 Context
是 child ctx, CancelFunc
是取消該 ctx 的函數。基於這個特性呢,經過多次派生,context
是一個樹形結構
context tree
如上圖所示,是一個多叉樹。如果 root
調用 cancel 函數那麼所有 children
也都會級聯 cancel, 因爲保存 children
的是一個 map, 也就無所謂先序中序後序了。如果 ctx 1-1
cancel, 那麼他的 children
都會 cancel, 但是 root
與 ctx 1-2
則不會受影響。
業務代碼當調用棧比較深時,就會出現這個多叉樹的形狀,另外 http 庫己經集成了 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
看到原理,級聯 cancel 所有 children
場景
來看一下使用場景吧,以一個標準的 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:
}
}
}
首先基於參數傳進來的 parent ctx
生成了 child ctx
與 cancel
函數。然後 Watch
時傳入 child ctx
, 如果此時 parent ctx
被外層 cancel 的話,child ctx
也會被 cancel, rch
會被 etcd clientv3
關閉,然後 for 循環走到 select 邏輯,此時 child ctx
被取消了,所以 <-ctx.Done()
生效,watch
函數返回。
其於 context
可以很好的做到多個 goroutine 協作,超時管理,大大簡化了開發工作。
Bad Cases
那我們看幾個錯誤使用 context
的案例,都非常經典
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
函數的存在,parent
也會引用 child
(當 parent 是 cancelCtx 類型時).
如果此時打印 ctx
, 就會遞歸調用 String()
方法,就會把 key/value 打印出來。如果此時 value 是非線程安全的,比如 map, 就會引發 concurrent read and write
panic.
這個案例就是 http 標準庫的實現 server.go:2906[2] 行代碼,把 http server 保存到 ctx 中
ctx := context.WithValue(baseCtx, ServerContextKey, srv)
最後調用業務層代碼時把 ctx 傳給了用戶
go c.serve(connCtx)
如果此時打印 ctx, 就會打印 http srv
結構體,這裏面就有 map. 感興趣的可以做個實驗,拿 ab 壓測很容易復現。
2. 提前超時
func test(){
ctx, cancel := context.WithCancel(ctx)
defer cancel()
doSomething(ctx)
}
func doSomething(ctx){
go doOthers(ctx)
}
當調用棧較深,多人合作時很容易產生這種情況。其實還是沒明白 ctx cancel 工作原理,異步 go 出去的業務邏輯需要基於 context.Background()
再派生 child ctx
, 否則就會提前超時返回
3. 自定義 ctx
理論上沒必要自定義 ctx, 相比官方實現,自定義有個很大的開銷在於 child
如何響應 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 去監測。本來業務代碼己經 goroutine 滿天飛了,不加節制的使用只會增加系統負擔。
另外聽說某大公司嫌棄這個 map, 想要使用數組重寫一版:(
原則
最後來總結下 context
使用的幾個原則:
-
除了框架層不要使用
WithValue
攜帶業務數據,這個類型是interface{}
, 編譯期無法確定,運行時 assert 有開銷。如果真要攜帶也要用 thread-safe 的數據 -
一定不要打印
context
, 尤其是從 http 標準庫派生出來的,誰知道里面存了什麼 -
context
做爲第一個參數傳給函數,而不是當成結構體的成員字段來使用 (雖然 etcd 代碼也這麼用) -
儘可能不要自定義用戶層
context
,除非收益巨大 -
異步 goroutine 邏輯使用
context
時要清楚誰還持有,會不會提前超時 -
派生出來的
child ctx
一定要配合defer cancel()
使用,釋放資源
小結
這次分享就這些,以後面還會分享更多的內容,如果感興趣,可以關注並轉發 (:
參考資料
[1]
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,
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/8iBQWs7LAXEMCoXc5PpSaQ