Golang context.Context介紹
近日某公眾號連推2篇關於context的文章,圖文不符的錯誤多處,也不適合我理解,因此檢視官方文件後總結一篇筆記。
context package - context - pkg.go.dev
type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key interface{}) interface{} }
本文不會直接講述context的設計初衷和由來,也不會直接講述context相比於其他併發控制方式的優劣。
本文旨在通過解析context包官方文件和示例來探明context的使用方式,從而反推其使用場景。
這裡先貼一下官網文件的Overview部分的簡單直譯。這部分內容描述了context包的使用方式、主要結構、使用場景,當寫完整篇筆記後再來印證可以更深刻理解。
一、Overview部分直譯,為便於理解有刪減,完整內容檢視官網連結:
context包提供了Context型別,這種型別可以承載deadlines、取消訊號等可以在API邊界和程序之間傳遞訊息的物件。
向server發出請求時應當建立一個context,server處理呼叫應當接收context。整個呼叫鏈中context應當作為引數被處理函式傳遞。這些context可以是也最好是WithCancel, WithDeadline, WithTimeout 或者 WithValue這些衍生出來的child context。當一個Context被取消時,他的child context也會取消。
WithCancel, WithDeadline, WithTimeout這幾個函式接收一個父Context物件,返回子Context物件和一個CancelFunc。當呼叫對應的CancelFunc時,對應的子Context物件就會被取消。呼叫CancelFunc失敗則child context就會洩露直到父context被取消或者自身超時。
使用context的程式應當遵循以下規則,以便允許靜態分析工具可以獲知context的傳播鏈路:
1. 不要在struct type中儲存context,而應當將其作為函式的引數進行傳遞,即想要使用context時應該給函式額外加一個ctx的引數。
func DoSomething(ctx context.Context, arg Arg) error { // ... use ctx ... }
2. 永遠不要傳遞nil context,如果你不確定該使用哪種context,那麼可以先傳個context.TODO替代。
3. Values不應當用作業務引數的傳遞(雖然這麼做確實可以),而應當用來在APIs、processes之間傳遞訊息。
4. 多個goroutine函式可以共用Context, context是併發安全的。
可以通過此地址檢視示例:Go Concurrency Patterns: Context - go.dev,獲知server是如何使用context傳遞訊息的。
二、context包提供了四種child context使用示例:
Context介面並不需要我們自己實現,context包已經提供了2個函式(context.Background()和context.TODO())來返回空Context型別,並提供了4個With開頭函式來生成具有特定功能的child Context。
- WithCancel
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
WithCancel返回一個child Context ctx,相比於輸入的parent,其重寫了Done(),實現的功能是:當CancelFunc被呼叫或parent的Done被寫入時,ctx的Done channel會被寫入(struct{}{}的空訊息),使用ctx的goroutine就可以通過讀取ctx.Done()來獲知取消訊號了。
package main import ( "context" "fmt" ) func main() { // gen是一個函式,用於不斷的返回整數數字,因為是返回的是隻讀的unbuffered channel,因此只有當返回值被消費時才會繼續返回下一個 gen := func(ctx context.Context) <-chan int { dst := make(chan int) n := 1 go func() { // 在gen內部,通過for select不斷的檢查ctx.Done()資訊來確定自己是否需要return,未接收到ctx.Done()訊息時就返回數字等待被消費 for { select { case <-ctx.Done(): return // 當從ctx.Done()接收到訊息時return函式,防止洩露 case dst <- n: n++ } } }() return dst } // 在gen外部使用WithCancel建立一個ctx,可以看到其parent是context.Background(),context.Background()返回一個非nil的空Context ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 當主函式退出時執行cancel函式,此時ctx.Done() channel就會被寫入,從而使gen退出 // 主函式遍歷gen()的輸出,當n=5時break迴圈,break之後defer cancel()觸發,之後上述case <-ctx.Done():被觸發從而退出gen函式 for n := range gen(ctx) { fmt.Println(n) if n == 5 { break } } // 試想下如果沒有context會怎樣: 當main函式for n := range gen(ctx) break之後,gen()依然會無限迴圈導致goroutine洩露(當然在本例中並不會,因為main函式執行完之後整個程序就退出了)
- WithDeadline
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
WithDeadline返回一個child Context ctx,相比於輸入的parent,其deadline不晚於指定的d時刻。如果parent的deadline比d更早,那麼按parent的deadline來算。
什麼情況下ctx的Done channel會有訊息:1. 當到達deadline時刻 2.CancelFunc被呼叫 3.parent的Done channel被寫入。
package main import ( "context" "fmt" "time" ) const shortDuration = 1 * time.Millisecond func main() { d := time.Now().Add(shortDuration) // 定義一個基於當前時間1ms後的時刻:d ctx, cancel := context.WithDeadline(context.Background(), d) // 將d作為ctx超時的時刻 defer cancel() // 當main goroutine結束時呼叫cancel函式 select { case <-time.After(1 * time.Second): fmt.Println("overslept") case <-ctx.Done(): fmt.Println(ctx.Err()) } // 檢查time.After(1 * time.Second)和ctx.Done()哪個channel有訊息,都沒訊息就阻塞於此一直檢查,發現一個有訊息就執行對應邏輯然後執行defer cancel() // 主函式直到結束才會呼叫cancel(),time.After(1 * time.Second)時長高達1s,而deadline時刻只有1ms的長度,所以在1ms後ctx.Done()就會因為deadline到達而被寫入,因此這個select會在1ms後就直接接收到ctx.Done()訊息,然後執行fmt.Println(ctx.Err()),打印出錯誤:context deadline exceeded。 // 在這之後繼續執行defer cancel()會繼續給ctx.Done() channel發訊息,那會遇到send on closed channel的panic嗎?不會,Done()的返回是冪等的:Successive calls to Done return the same value.。 // 既然ctx的Done()會因為deadline到達而被提前寫入訊息,那還有必要defer cancel()嗎?官網的解釋是有必要,因為這可以確保ctx及其父context的釋放。 }
- WithTimeout
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
WithValue相比於之前的3個With開頭的函式有區別,他不會返回CancelFunc函式,可以為Context.Value傳值。
但是官網明確說明WithValue的key不應該是字串或任何其他內建型別,以免與context包自用的產生衝突。應當傳遞業務自定義型別。直接示例之:
package main import ( "context" "fmt" ) func main() { type favContextKey string // 自定義一個string的型別:favContextKey // 定義函式f,接收ctx和k引數 f := func(ctx context.Context, k favContextKey) { // 獲取ctx中儲存的key-value pair,如果匹配到了輸入引數k對應的值,則把值存入v中並列印 if v := ctx.Value(k); v != nil { fmt.Println("found value:", v) return } // 如果在ctx中未匹配到k對應的value,那麼列印未找到資訊 fmt.Println("key not found:", k) } k := favContextKey("language") ctx := context.WithValue(context.Background(), k, "Go") // WithValue返回的ctx儲存了一個key-value pair,其key為k,value為Go f(ctx, k) f(ctx, favContextKey("color")) }
三、總結:
一般來說當一個goroutine啟動之後我們就很難控制他的運行了,除非預先定義了一個channel,然後在goroutine內部不斷的檢查channel的訊息來決定後繼執行邏輯。
基於此邏輯我們來總結context的使用:
通過上述3個示例,我們可以看到整個context包其實就是圍繞Context.Done()這個channel來做文章的。無論是CancelFunc還是Deadline(),Err(),其目的都是輔助Done(),目的就是當滿足某些條件時給Done channel傳遞訊息,在goroutine內部則使用select檢查ctx.Done()是否有訊息來決定下一步的執行邏輯。
context包提供了一個更人性化的channel定義方式,免了開發者自定義各種通訊channel的煩惱。
與sync.WaitGroup的區別何在?
很明顯的wg用於等到本組內的goroutine自然終結,而context提供了主動終結goroutine的能力,雖然這種能力是建立在需要goroutine內部檢查ctx相關狀態的基礎上的。
最後WithValue的使用與其他幾個有很大區別,看起來更加的靈活,可以為goroutine傳遞更豐富的訊息,有待挖掘補充。
想建一個數據庫技術和程式設計技術的交流群,用於磨鍊提升技術能力,目前主要專注於Golang和Python以及TiDB,MySQL資料庫,群號:231338927,建群日期:2019.04.26,截止2021.02.01人數:300人 ... 如發現部落格錯誤,可直接留言指正,感謝。