Go Context 的踩坑經(jīng)歷

引言

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)容:

  1. context 的使用。

  2. context 實現(xiàn)原理,哪些是需要注意的地方。

  3. 在實踐中遇到的問題,分析問題產(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 樹得以退出。

WithDeadlineWithTimeoutWithCancel 多了一個時間參數(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() 來終止所有的 cancelCtxdone 用來標識是否已被 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ū)塊鏈 正在招生中

各位小伙伴們,歡迎試聽和咨詢:


掃碼添加小助手微信,備注"公開課,來源簡書",進入分享群
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,527評論 6 544
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 99,687評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,640評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經(jīng)常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,957評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,682評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 56,011評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,009評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 43,183評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 49,714評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 41,435評論 3 359
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,665評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,148評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,838評論 3 350
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,251評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,588評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,379評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,627評論 2 380

推薦閱讀更多精彩內(nèi)容

  • 概念:微服務就是一些可獨立運行、可協(xié)同工作的小的服務。微服務是現(xiàn)在特別流行的服務,微服務的字面意思是大家都很好理解...
    程序員技術(shù)圈閱讀 3,366評論 2 47
  • [TOC] Golang Context分析 Context背景 和 適用場景 golang在1.6.2的時候還沒...
    AllenWu閱讀 11,556評論 0 30
  • 同學C:大學時,因少不更事,也許做過不少荒唐事,然對C做過的一件事讓我至今都覺得后悔! 剛進大學時,C喜歡上了我,...
    小艷子0561閱讀 140評論 0 0
  • 耐心等待鉆石沒嘴破土而出,可再精美的鉆石也得有人發(fā)現(xiàn)然后開采出來才會擁有價值,可如果你不是一個鉆石再怎么等待,即便...
    耕耘生活閱讀 441評論 7 25
  • 【分享時間】:2017-01-24 【分享地點】:DISC雙證班F41官方群 【分享主題】:DISC激勵我們的那些...
    生命君閱讀 620評論 1 1