引言
context 是 Go 中廣泛使用的程序包,由 Google 官方開發(fā),在 1.7 版本引入。它用來簡化在多個 go routine 傳遞上下文數(shù)據(jù)、(手動/超時)中止 routine 樹等操作,比如,官方 http 包使用 context 傳遞請求的上下文數(shù)據(jù),gRpc 使用 context 來終止某個請求產(chǎn)生的 routine 樹。由于它使用簡單,現(xiàn)在基本成了編寫 go 基礎(chǔ)庫的通用規(guī)范。筆者在使用 context 上有一些經(jīng)驗,遂分享下。
本文主要談談以下幾個方面的內(nèi)容:
context 的使用。
context 實現(xiàn)原理,哪些是需要注意的地方。
在實踐中遇到的問題,分析問題產(chǎn)生的原因。
1.使用
1.1 使用核心接口 Context
type Context interface { // Deadline returns the time when work done on behalf of this context // should be canceled. Deadline returns ok==false when no deadline is // set. Deadline() (deadline time.Time, ok bool) // Done returns a channel that's closed when work done on behalf of this // context should be canceled. Done() <-chan struct{} // Err returns a non-nil error value after Done is closed. Err() error // Value returns the value associated with this context for key. Value(key interface{}) interface{}}
簡單介紹一下其中的方法:
Done 會返回一個 channel,當該 context 被取消的時候,該 channel 會被關(guān)閉,同時對應的使用該 context 的 routine 也應該結(jié)束并返回。
Context 中的方法是協(xié)程安全的,這也就代表了在父 routine 中創(chuàng)建的context,可以傳遞給任意數(shù)量的 routine 并讓他們同時訪問。
Deadline 會返回一個超時時間,routine 獲得了超時時間后,可以對某些 io 操作設(shè)定超時時間。
Value 可以讓 routine 共享一些數(shù)據(jù),當然獲得數(shù)據(jù)是協(xié)程安全的。
在請求處理的過程中,會調(diào)用各層的函數(shù),每層的函數(shù)會創(chuàng)建自己的 routine,是一個 routine 樹。所以,context 也應該反映并實現(xiàn)成一棵樹。
要創(chuàng)建 context 樹,第一步是要有一個根結(jié)點。context.Background 函數(shù)的返回值是一個空的 context,經(jīng)常作為樹的根結(jié)點,它一般由接收請求的第一個 routine 創(chuàng)建,不能被取消、沒有值、也沒有過期時間。
func Background() Context
之后該怎么創(chuàng)建其它的子孫節(jié)點呢?context包為我們提供了以下函數(shù):
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)func WithValue(parent Context, key interface{}, val interface{}) Context
這四個函數(shù)的第一個參數(shù)都是父 context,返回一個 Context 類型的值,這樣就層層創(chuàng)建出不同的節(jié)點。子節(jié)點是從復制父節(jié)點得到的,并且根據(jù)接收的函數(shù)參數(shù)保存子節(jié)點的一些狀態(tài)值,然后就可以將它傳遞給下層的 routine 了。
WithCancel 函數(shù),返回一個額外的 CancelFunc 函數(shù)類型變量,該函數(shù)類型的定義為:
type CancelFunc func()
調(diào)用 CancelFunc 對象將撤銷對應的 Context 對象,這樣父結(jié)點的所在的環(huán)境中,獲得了撤銷子節(jié)點 context 的權(quán)利,當觸發(fā)某些條件時,可以調(diào)用 CancelFunc 對象來終止子結(jié)點樹的所有 routine。在子節(jié)點的 routine 中,需要用類似下面的代碼來判斷何時退出 routine:
select { case <-cxt.Done(): // do some cleaning and return}
根據(jù) cxt.Done() 判斷是否結(jié)束。當頂層的 Request 請求處理結(jié)束,或者外部取消了這次請求,就可以 cancel 掉頂層 context,從而使整個請求的 routine 樹得以退出。
WithDeadline 和 WithTimeout 比 WithCancel 多了一個時間參數(shù),它指示 context 存活的最長時間。如果超過了過期時間,會自動撤銷它的子 context。所以 context 的生命期是由父 context 的 routine 和 deadline 共同決定的。
WithValue 返回 parent 的一個副本,該副本保存了傳入的 key/value,而調(diào)用Context 接口的 Value(key) 方法就可以得到 val。注意在同一個 context 中設(shè)置key/value,若 key 相同,值會被覆蓋。
關(guān)于更多的使用示例,可參考官方博客。
2.原理
2.1 輸入標題上下文數(shù)據(jù)的存儲與查詢
type valueCtx struct { Context key, val interface{}}func WithValue(parent Context, key, val interface{}) Context { if key == nil { panic("nil key") } ...... return &valueCtx{parent, key, val}}func (c *valueCtx) Value(key interface{}) interface{} { if c.key == key { return c.val } return c.Context.Value(key)}
context 上下文數(shù)據(jù)的存儲就像一個樹,每個結(jié)點只存儲一個 key/value 對。WithValue() 保存一個 key/value 對,它將父 context 嵌入到新的子 context,并在節(jié)點中保存了 key/value 數(shù)據(jù)。Value() 查詢 key 對應的 value 數(shù)據(jù),會從當前 context 中查詢,如果查不到,會遞歸查詢父 context 中的數(shù)據(jù)。
值得注意的是,context 中的上下文數(shù)據(jù)并不是全局的,它只查詢本節(jié)點及父節(jié)點們的數(shù)據(jù),不能查詢兄弟節(jié)點的數(shù)據(jù)。
2.2 手動 cancel 和超時 cancel
cancelCtx 中嵌入了父 Context,實現(xiàn)了canceler 接口:
type cancelCtx struct { Context // 保存parent Context done chan struct{} mu sync.Mutex children map[canceler]struct{} err error}// A canceler is a context type that can be canceled directly. The// implementations are *cancelCtx and *timerCtx.type canceler interface { cancel(removeFromParent bool, err error) Done() <-chan struct{}}
cancelCtx 結(jié)構(gòu)體中 children 保存它的所有子 canceler, 當外部觸發(fā) cancel時,會調(diào)用 children 中的所有 cancel() 來終止所有的 cancelCtx 。done 用來標識是否已被 cancel。當外部觸發(fā) cancel、或者父 Context 的 channel 關(guān)閉時,此 done 也會關(guān)閉。
type timerCtx struct { cancelCtx //cancelCtx.Done()關(guān)閉的時機:1)用戶調(diào)用cancel 2)deadline到了 3)父Context的done關(guān)閉了 timer *time.Timer deadline time.Time}func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) { ...... c := &timerCtx{ cancelCtx: newCancelCtx(parent), deadline: deadline, } propagateCancel(parent, c) d := time.Until(deadline) if d <= 0 { c.cancel(true, DeadlineExceeded) // deadline has already passed return c, func() { c.cancel(true, Canceled) } } c.mu.Lock() defer c.mu.Unlock() if c.err == nil { c.timer = time.AfterFunc(d, func() { c.cancel(true, DeadlineExceeded) }) } return c, func() { c.cancel(true, Canceled) }}
timerCtx 結(jié)構(gòu)體中 deadline 保存了超時的時間,當超過這個時間,會觸發(fā)cancel 。
可以看出,cancelCtx 也是一棵樹,當觸發(fā) cancel 時,會 cancel 本結(jié)點和其子樹的所有 cancelCtx。
3.遇到的問題
3.1 背景
某天,為了給我們的系統(tǒng)接入 etrace (內(nèi)部的鏈路跟蹤系統(tǒng)),需要在 gRpc/Mysql/Redis/MQ 操作過程中傳遞 requestId、rpcId,我們的解決方案是 Context 。
所有 Mysql、MQ、Redis 的操作接口的第一個參數(shù)都是 context,如果這個context (或其父 context )被 cancel了,則操作會失敗。
func (tx *Tx) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)func(process func(context.Context, redis.Cmder) error) func(context.Context, redis.Cmder) errorfunc (ch *Channel) Consume(ctx context.Context, handler Handler, queue string, dc <-chan amqp.Delivery) errorfunc (ch *Channel) Publish(ctx context.Context, exchange, key string, mandatory, immediate bool, msg Publishing) (err error)
上線后,遇到一系列的坑......
3.2 Case 1
現(xiàn)象:上線后,5 分鐘后所有用戶登錄失敗,不斷收到報警。
原因:程序中使用 localCache,會每 5 分鐘 Refresh (調(diào)用注冊的回調(diào)函數(shù))一次所緩存的變量。localCache 中保存了一個 context,在調(diào)用回調(diào)函數(shù)時會傳進去。如果回調(diào)函數(shù)依賴 context,可能會產(chǎn)生意外的結(jié)果。
程序中,回調(diào)函數(shù) getAppIDAndAlias 的功能是從 mysql 中讀取相關(guān)數(shù)據(jù)。如果 ctx 被 cancel 了,會直接返回失敗。
func getAppIDAndAlias(ctx context.Context, appKey, appSecret string) (string, string, error)
第一次 localCache.Get(ctx, appKey, appSeret) 傳的 ctx 是 gRpc call 傳進來的 context,而 gRpc 在請求結(jié)束或失敗時會 cancel 掉 context,導致之后 cache Refresh() 時,執(zhí)行失敗。
解決方法:在 Refresh 時不使用 localCache 的 context,使用一個不會 cancel的 context。
3.3 Case 2
現(xiàn)象:上線后,不斷收到報警( sys err 過多)。看 log/etrace 產(chǎn)生 2 種 sys err:
context canceled
sql: Transaction has already been committed or rolled back
3.3.1 背景及原因
Ticket 是處理 Http 請求的服務,它使用 Restful 風格的協(xié)議。由于程序內(nèi)部使用的是 gRpc 協(xié)議,需要某個組件進行協(xié)議轉(zhuǎn)換,我們引入了 grpc-gateway,用它來實現(xiàn) Restful 轉(zhuǎn)成 gRpc 的互轉(zhuǎn)。
復現(xiàn) context canceled 的流程如下:
客戶端發(fā)送 http restful 請求。
grpc-gateway 與客戶端建立連接,接收請求,轉(zhuǎn)換參數(shù),調(diào)用后面的 grpc-server。
grpc-server 處理請求。其中,grpc-server 會對每個請求啟一個stream,由這個 stream 創(chuàng)建 context。
客戶端連接斷開。
grpc-gateway 收到連接斷開的信號,導致 context cancel。grpc client 在發(fā)送 rpc 請求后由于外部異常使它的請求終止了(即它的 context 被cancel ),會發(fā)一個 RST_STREAM。
grpc server 收到后,馬上終止請求(即 grpc server 的 stream context被 cancel )。
可以看出,是因為 gRpc handler 在處理過程中連接被斷開。
sql: Transaction has already been committed or rolled back 產(chǎn)生的原因:
程序中使用了官方 database 包來執(zhí)行 db transaction。其中,在 db.BeginTx 時,會啟一個協(xié)程 awaitDone:
func (tx *Tx) awaitDone() { // Wait for either the transaction to be committed or rolled // back, or for the associated context to be closed. <-tx.ctx.Done() // Discard and close the connection used to ensure the // transaction is closed and the resources are released. This // rollback does nothing if the transaction has already been // committed or rolled back. tx.rollback(true)}
在 context 被 cancel 時,會進行 rollback(),而 rollback 時,會操作原子變量。之后,在另一個協(xié)程中 tx.Commit() 時,會判斷原子變量,如果變了,會拋出錯誤。
3.3.2 解決方法
這兩個 error 都是由連接斷開導致的,是正常的。可忽略這兩個 error。
3.4 Case 3
上線后,每兩天左右有 1~2 次的 mysql 事務阻塞,導致請求耗時達到 120 秒。在盤古(內(nèi)部的 mysql 運維平臺)中查詢到所有阻塞的事務在處理同一條記錄。
3.4.1 處理過程
1. 初步懷疑是跨機房的多個事務操作同一條記錄導致的。由于跨機房操作,耗時會增加,導致阻塞了其他機房執(zhí)行的 db 事務。
2. 出現(xiàn)此現(xiàn)象時,暫時將某個接口降級。降低多個事務操作同一記錄的概率。
3. 減少事務的個數(shù)。
將單條 sql 的事務去掉
通過業(yè)務邏輯的轉(zhuǎn)移減少不必要的事務
4. 調(diào)整 db 參數(shù) innodb_lock_wait_timeout(120s->50s)。這個參數(shù)指示 mysql 在執(zhí)行事務時阻塞的最大時間,將這個時間減少,來減少整個操作的耗時。考慮過在程序中指定事務的超時時間,但是 innodb_lock_wait_timeout 要么是全局,要么是 session 的。擔心影響到 session 上的其它 sql,所以沒設(shè)置。
5. 考慮使用分布式鎖來減少操作同一條記錄的事務的并發(fā)量。但由于時間關(guān)系,沒做這塊的改進。
6. DAL 同事發(fā)現(xiàn)有事務沒提交,查看代碼,找到 root cause。
原因是 golang 官方包 database/sql 會在某種競態(tài)條件下,導致事務既沒有 commit,也沒有 rollback。
3.4.2 源碼描述
開始事務 BeginTxx() 時會啟一個協(xié)程:
// awaitDone blocks until the context in Tx is canceled and rolls back// the transaction if it's not already done.func (tx *Tx) awaitDone() { // Wait for either the transaction to be committed or rolled // back, or for the associated context to be closed. <-tx.ctx.Done() // Discard and close the connection used to ensure the // transaction is closed and the resources are released. This // rollback does nothing if the transaction has already been // committed or rolled back. tx.rollback(true)}
tx.rollback(true) 中,會先判斷原子變量 tx.done 是否為 1,如果 1,則返回;如果是 0,則加 1,并進行 rollback 操作。
在提交事務 Commit() 時,會先操作原子變量 tx.done,然后判斷 context 是否被 cancel 了,如果被 cancel,則返回;如果沒有,則進行 commit 操作。
// Commit commits the transaction.func (tx *Tx) Commit() error { if !atomic.CompareAndSwapInt32(&tx.done, 0, 1) { return ErrTxDone } select { default: case <-tx.ctx.Done(): return tx.ctx.Err() } var err error withLock(tx.dc, func() { err = tx.txi.Commit() }) if err != driver.ErrBadConn { tx.closePrepared() } tx.close(err) return err}
如果先進行 commit() 過程中,先操作原子變量,然后 context 被 cancel,之后另一個協(xié)程在進行 rollback() 會因為原子變量置為 1 而返回。導致 commit() 沒有執(zhí)行,rollback() 也沒有執(zhí)行。
3.4.3 解決方法
解決方法可以是如下任一個:
在執(zhí)行事務時傳進去一個不會 cancel 的 context
修正 database/sql 源碼,然后在編譯時指定新的 go 編譯鏡像
我們之后給 Golang 提交了 patch,修正了此問題 ( 已合入 go 1.9.3)。
4.經(jīng)驗教訓
由于 go 大量的官方庫、第三方庫使用了 context,所以調(diào)用接收 context 的函數(shù)時要小心,要清楚 context 在什么時候 cancel,什么行為會觸發(fā) cancel。筆者在程序經(jīng)常使用 gRpc 傳出來的 context,產(chǎn)生了一些非預期的結(jié)果,之后花時間總結(jié)了 gRpc、內(nèi)部基礎(chǔ)庫中 context 的生命期及行為,以避免出現(xiàn)同樣的問題。
轉(zhuǎn)載
作者:包增輝
原文鏈接:https://zhuanlan.zhihu.com/p/34417106
公告通知
Golang 班、架構(gòu)師班、自動化運維班、區(qū)塊鏈 正在招生中
各位小伙伴們,歡迎試聽和咨詢: