引言
context 是 Go 中廣泛使用的程序包累提,由 Google 官方開(kāi)發(fā),在 1.7 版本引入。它用來(lái)簡(jiǎn)化在多個(gè) go routine 傳遞上下文數(shù)據(jù)、(手動(dòng)/超時(shí))中止 routine 樹(shù)等操作蠢涝,比如,官方 http 包使用 context 傳遞請(qǐng)求的上下文數(shù)據(jù)宜咒,gRpc 使用 context 來(lái)終止某個(gè)請(qǐng)求產(chǎn)生的 routine 樹(shù)惠赫。由于它使用簡(jiǎn)單,現(xiàn)在基本成了編寫 go 基礎(chǔ)庫(kù)的通用規(guī)范故黑。筆者在使用 context 上有一些經(jīng)驗(yàn),遂分享下庭砍。
本文主要談?wù)勔韵聨讉€(gè)方面的內(nèi)容:
context 的使用场晶。
context 實(shí)現(xiàn)原理,哪些是需要注意的地方怠缸。
在實(shí)踐中遇到的問(wèn)題诗轻,分析問(wè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{}}
簡(jiǎn)單介紹一下其中的方法:
Done 會(huì)返回一個(gè) channel揭北,當(dāng)該 context 被取消的時(shí)候扳炬,該 channel 會(huì)被關(guān)閉,同時(shí)對(duì)應(yīng)的使用該 context 的 routine 也應(yīng)該結(jié)束并返回搔体。
Context 中的方法是協(xié)程安全的恨樟,這也就代表了在父 routine 中創(chuàng)建的context,可以傳遞給任意數(shù)量的 routine 并讓他們同時(shí)訪問(wèn)疚俱。
Deadline 會(huì)返回一個(gè)超時(shí)時(shí)間劝术,routine 獲得了超時(shí)時(shí)間后,可以對(duì)某些 io 操作設(shè)定超時(shí)時(shí)間。
Value 可以讓 routine 共享一些數(shù)據(jù)养晋,當(dāng)然獲得數(shù)據(jù)是協(xié)程安全的衬吆。
在請(qǐng)求處理的過(guò)程中,會(huì)調(diào)用各層的函數(shù)绳泉,每層的函數(shù)會(huì)創(chuàng)建自己的 routine逊抡,是一個(gè) routine 樹(shù)。所以零酪,context 也應(yīng)該反映并實(shí)現(xiàn)成一棵樹(shù)冒嫡。
要?jiǎng)?chuàng)建 context 樹(shù),第一步是要有一個(gè)根結(jié)點(diǎn)蛾娶。context.Background 函數(shù)的返回值是一個(gè)空的 context灯谣,經(jīng)常作為樹(shù)的根結(jié)點(diǎn),它一般由接收請(qǐng)求的第一個(gè) routine 創(chuàng)建蛔琅,不能被取消胎许、沒(méi)有值、也沒(méi)有過(guò)期時(shí)間罗售。
func Background() Context
之后該怎么創(chuàng)建其它的子孫節(jié)點(diǎn)呢辜窑?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
這四個(gè)函數(shù)的第一個(gè)參數(shù)都是父 context,返回一個(gè) Context 類型的值寨躁,這樣就層層創(chuàng)建出不同的節(jié)點(diǎn)穆碎。子節(jié)點(diǎn)是從復(fù)制父節(jié)點(diǎn)得到的,并且根據(jù)接收的函數(shù)參數(shù)保存子節(jié)點(diǎn)的一些狀態(tài)值职恳,然后就可以將它傳遞給下層的 routine 了所禀。
WithCancel 函數(shù),返回一個(gè)額外的 CancelFunc 函數(shù)類型變量放钦,該函數(shù)類型的定義為:
type CancelFunc func()
調(diào)用 CancelFunc 對(duì)象將撤銷對(duì)應(yīng)的 Context 對(duì)象色徘,這樣父結(jié)點(diǎn)的所在的環(huán)境中,獲得了撤銷子節(jié)點(diǎn) context 的權(quán)利操禀,當(dāng)觸發(fā)某些條件時(shí)褂策,可以調(diào)用 CancelFunc 對(duì)象來(lái)終止子結(jié)點(diǎn)樹(shù)的所有 routine。在子節(jié)點(diǎn)的 routine 中颓屑,需要用類似下面的代碼來(lái)判斷何時(shí)退出 routine:
select { case <-cxt.Done(): // do some cleaning and return}
根據(jù) cxt.Done() 判斷是否結(jié)束斤寂。當(dāng)頂層的 Request 請(qǐng)求處理結(jié)束,或者外部取消了這次請(qǐng)求揪惦,就可以 cancel 掉頂層 context遍搞,從而使整個(gè)請(qǐng)求的 routine 樹(shù)得以退出。
WithDeadline 和 WithTimeout 比 WithCancel 多了一個(gè)時(shí)間參數(shù)丹擎,它指示 context 存活的最長(zhǎng)時(shí)間尾抑。如果超過(guò)了過(guò)期時(shí)間歇父,會(huì)自動(dòng)撤銷它的子 context。所以 context 的生命期是由父 context 的 routine 和 deadline 共同決定的再愈。
WithValue 返回 parent 的一個(gè)副本榜苫,該副本保存了傳入的 key/value,而調(diào)用Context 接口的 Value(key) 方法就可以得到 val翎冲。注意在同一個(gè) context 中設(shè)置key/value垂睬,若 key 相同,值會(huì)被覆蓋抗悍。
關(guān)于更多的使用示例驹饺,可參考官方博客。
2.原理
2.1 輸入標(biāo)題上下文數(shù)據(jù)的存儲(chǔ)與查詢
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ù)的存儲(chǔ)就像一個(gè)樹(shù)缴渊,每個(gè)結(jié)點(diǎn)只存儲(chǔ)一個(gè) key/value 對(duì)赏壹。WithValue() 保存一個(gè) key/value 對(duì),它將父 context 嵌入到新的子 context衔沼,并在節(jié)點(diǎn)中保存了 key/value 數(shù)據(jù)蝌借。Value() 查詢 key 對(duì)應(yīng)的 value 數(shù)據(jù),會(huì)從當(dāng)前 context 中查詢指蚁,如果查不到菩佑,會(huì)遞歸查詢父 context 中的數(shù)據(jù)。
值得注意的是凝化,context 中的上下文數(shù)據(jù)并不是全局的稍坯,它只查詢本節(jié)點(diǎn)及父節(jié)點(diǎn)們的數(shù)據(jù),不能查詢兄弟節(jié)點(diǎn)的數(shù)據(jù)搓劫。
2.2 手動(dòng) cancel 和超時(shí) cancel
cancelCtx 中嵌入了父 Context瞧哟,實(shí)現(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, 當(dāng)外部觸發(fā) cancel時(shí)枪向,會(huì)調(diào)用 children 中的所有 cancel() 來(lái)終止所有的 cancelCtx 绢涡。done 用來(lái)標(biāo)識(shí)是否已被 cancel。當(dāng)外部觸發(fā) cancel遣疯、或者父 Context 的 channel 關(guān)閉時(shí),此 done 也會(huì)關(guān)閉凿傅。
type timerCtx struct { cancelCtx //cancelCtx.Done()關(guān)閉的時(shí)機(jī):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 保存了超時(shí)的時(shí)間缠犀,當(dāng)超過(guò)這個(gè)時(shí)間,會(huì)觸發(fā)cancel 聪舒。
可以看出辨液,cancelCtx 也是一棵樹(shù),當(dāng)觸發(fā) cancel 時(shí)箱残,會(huì) cancel 本結(jié)點(diǎn)和其子樹(shù)的所有 cancelCtx滔迈。
3.遇到的問(wèn)題
3.1 背景
某天止吁,為了給我們的系統(tǒng)接入 etrace (內(nèi)部的鏈路跟蹤系統(tǒng)),需要在 gRpc/Mysql/Redis/MQ 操作過(guò)程中傳遞 requestId燎悍、rpcId敬惦,我們的解決方案是 Context 。
所有 Mysql谈山、MQ俄删、Redis 的操作接口的第一個(gè)參數(shù)都是 context,如果這個(gè)context (或其父 context )被 cancel了奏路,則操作會(huì)失敗畴椰。
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 分鐘后所有用戶登錄失敗斜脂,不斷收到報(bào)警。
原因:程序中使用 localCache触机,會(huì)每 5 分鐘 Refresh (調(diào)用注冊(cè)的回調(diào)函數(shù))一次所緩存的變量帚戳。localCache 中保存了一個(gè) context,在調(diào)用回調(diào)函數(shù)時(shí)會(huì)傳進(jìn)去威兜。如果回調(diào)函數(shù)依賴 context销斟,可能會(huì)產(chǎn)生意外的結(jié)果。
程序中椒舵,回調(diào)函數(shù) getAppIDAndAlias 的功能是從 mysql 中讀取相關(guān)數(shù)據(jù)蚂踊。如果 ctx 被 cancel 了,會(huì)直接返回失敗笔宿。
func getAppIDAndAlias(ctx context.Context, appKey, appSecret string) (string, string, error)
第一次 localCache.Get(ctx, appKey, appSeret) 傳的 ctx 是 gRpc call 傳進(jìn)來(lái)的 context犁钟,而 gRpc 在請(qǐng)求結(jié)束或失敗時(shí)會(huì) cancel 掉 context,導(dǎo)致之后 cache Refresh() 時(shí)泼橘,執(zhí)行失敗涝动。
解決方法:在 Refresh 時(shí)不使用 localCache 的 context,使用一個(gè)不會(huì) cancel的 context炬灭。
3.3 Case 2
現(xiàn)象:上線后醋粟,不斷收到報(bào)警( sys err 過(guò)多)≈毓椋看 log/etrace 產(chǎn)生 2 種 sys err:
context canceled
sql: Transaction has already been committed or rolled back
3.3.1 背景及原因
Ticket 是處理 Http 請(qǐng)求的服務(wù)米愿,它使用 Restful 風(fēng)格的協(xié)議。由于程序內(nèi)部使用的是 gRpc 協(xié)議鼻吮,需要某個(gè)組件進(jìn)行協(xié)議轉(zhuǎn)換育苟,我們引入了 grpc-gateway,用它來(lái)實(shí)現(xiàn) Restful 轉(zhuǎn)成 gRpc 的互轉(zhuǎn)椎木。
復(fù)現(xiàn) context canceled 的流程如下:
客戶端發(fā)送 http restful 請(qǐng)求违柏。
grpc-gateway 與客戶端建立連接博烂,接收請(qǐng)求,轉(zhuǎn)換參數(shù)漱竖,調(diào)用后面的 grpc-server禽篱。
grpc-server 處理請(qǐng)求。其中闲孤,grpc-server 會(huì)對(duì)每個(gè)請(qǐng)求啟一個(gè)stream谆级,由這個(gè) stream 創(chuàng)建 context。
客戶端連接斷開(kāi)讼积。
grpc-gateway 收到連接斷開(kāi)的信號(hào)肥照,導(dǎo)致 context cancel。grpc client 在發(fā)送 rpc 請(qǐng)求后由于外部異常使它的請(qǐng)求終止了(即它的 context 被cancel )勤众,會(huì)發(fā)一個(gè) RST_STREAM舆绎。
grpc server 收到后,馬上終止請(qǐng)求(即 grpc server 的 stream context被 cancel )们颜。
可以看出吕朵,是因?yàn)?gRpc handler 在處理過(guò)程中連接被斷開(kāi)。
sql: Transaction has already been committed or rolled back 產(chǎn)生的原因:
程序中使用了官方 database 包來(lái)執(zhí)行 db transaction窥突。其中努溃,在 db.BeginTx 時(shí),會(huì)啟一個(gè)協(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 時(shí)阻问,會(huì)進(jìn)行 rollback()梧税,而 rollback 時(shí),會(huì)操作原子變量称近。之后第队,在另一個(gè)協(xié)程中 tx.Commit() 時(shí),會(huì)判斷原子變量刨秆,如果變了凳谦,會(huì)拋出錯(cuò)誤。
3.3.2 解決方法
這兩個(gè) error 都是由連接斷開(kāi)導(dǎo)致的衡未,是正常的尸执。可忽略這兩個(gè) error缓醋。
3.4 Case 3
上線后剔交,每?jī)商熳笥矣?1~2 次的 mysql 事務(wù)阻塞,導(dǎo)致請(qǐng)求耗時(shí)達(dá)到 120 秒改衩。在盤古(內(nèi)部的 mysql 運(yùn)維平臺(tái))中查詢到所有阻塞的事務(wù)在處理同一條記錄。
3.4.1 處理過(guò)程
1. 初步懷疑是跨機(jī)房的多個(gè)事務(wù)操作同一條記錄導(dǎo)致的驯镊。由于跨機(jī)房操作葫督,耗時(shí)會(huì)增加竭鞍,導(dǎo)致阻塞了其他機(jī)房執(zhí)行的 db 事務(wù)。
2. 出現(xiàn)此現(xiàn)象時(shí)橄镜,暫時(shí)將某個(gè)接口降級(jí)偎快。降低多個(gè)事務(wù)操作同一記錄的概率。
3. 減少事務(wù)的個(gè)數(shù)洽胶。
將單條 sql 的事務(wù)去掉
通過(guò)業(yè)務(wù)邏輯的轉(zhuǎn)移減少不必要的事務(wù)
4. 調(diào)整 db 參數(shù) innodb_lock_wait_timeout(120s->50s)晒夹。這個(gè)參數(shù)指示 mysql 在執(zhí)行事務(wù)時(shí)阻塞的最大時(shí)間,將這個(gè)時(shí)間減少姊氓,來(lái)減少整個(gè)操作的耗時(shí)丐怯。考慮過(guò)在程序中指定事務(wù)的超時(shí)時(shí)間翔横,但是 innodb_lock_wait_timeout 要么是全局读跷,要么是 session 的。擔(dān)心影響到 session 上的其它 sql禾唁,所以沒(méi)設(shè)置效览。
5. 考慮使用分布式鎖來(lái)減少操作同一條記錄的事務(wù)的并發(fā)量。但由于時(shí)間關(guān)系荡短,沒(méi)做這塊的改進(jìn)丐枉。
6. DAL 同事發(fā)現(xiàn)有事務(wù)沒(méi)提交,查看代碼掘托,找到 root cause瘦锹。
原因是 golang 官方包 database/sql 會(huì)在某種競(jìng)態(tài)條件下,導(dǎo)致事務(wù)既沒(méi)有 commit烫映,也沒(méi)有 rollback沼本。
3.4.2 源碼描述
開(kāi)始事務(wù) BeginTxx() 時(shí)會(huì)啟一個(gè)協(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) 中,會(huì)先判斷原子變量 tx.done 是否為 1锭沟,如果 1抽兆,則返回;如果是 0族淮,則加 1辫红,并進(jìn)行 rollback 操作。
在提交事務(wù) Commit() 時(shí)祝辣,會(huì)先操作原子變量 tx.done贴妻,然后判斷 context 是否被 cancel 了,如果被 cancel蝙斜,則返回名惩;如果沒(méi)有,則進(jìn)行 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}
如果先進(jìn)行 commit() 過(guò)程中娩鹉,先操作原子變量攻谁,然后 context 被 cancel,之后另一個(gè)協(xié)程在進(jìn)行 rollback() 會(huì)因?yàn)樵幼兞恐脼?1 而返回弯予。導(dǎo)致 commit() 沒(méi)有執(zhí)行戚宦,rollback() 也沒(méi)有執(zhí)行。
3.4.3 解決方法
解決方法可以是如下任一個(gè):
在執(zhí)行事務(wù)時(shí)傳進(jìn)去一個(gè)不會(huì) cancel 的 context
修正 database/sql 源碼锈嫩,然后在編譯時(shí)指定新的 go 編譯鏡像
我們之后給 Golang 提交了 patch受楼,修正了此問(wèn)題 ( 已合入 go 1.9.3)。
4.經(jīng)驗(yàn)教訓(xùn)
由于 go 大量的官方庫(kù)呼寸、第三方庫(kù)使用了 context艳汽,所以調(diào)用接收 context 的函數(shù)時(shí)要小心,要清楚 context 在什么時(shí)候 cancel等舔,什么行為會(huì)觸發(fā) cancel骚灸。筆者在程序經(jīng)常使用 gRpc 傳出來(lái)的 context,產(chǎn)生了一些非預(yù)期的結(jié)果慌植,之后花時(shí)間總結(jié)了 gRpc甚牲、內(nèi)部基礎(chǔ)庫(kù)中 context 的生命期及行為,以避免出現(xiàn)同樣的問(wèn)題蝶柿。
轉(zhuǎn)載
作者:包增輝
原文鏈接:https://zhuanlan.zhihu.com/p/34417106
公告通知
Golang 班丈钙、架構(gòu)師班、自動(dòng)化運(yùn)維班交汤、區(qū)塊鏈 正在招生中
各位小伙伴們雏赦,歡迎試聽(tīng)和咨詢: