Golang Context是好的設計嗎?
最近實現系統的分散式日誌與事務管理時,在尋求所謂的全域性唯一Goroutine ID無果之後,決定還是簡單利用Context機制實現了基本的想法,不夠高明,但是好用。於是對它當初的設計比較好奇,便有了此文。
1、What Context
Context是Golang官方定義的一個package,它定義了Context型別,裡面包含了Deadline/Done/Err方法以及繫結到Context上的成員變數值Value,具體定義如下:
type Context interface { // 返回Context的超時時間(超時返回場景) Deadline() (deadline time.Time, ok bool) // 在Context超時或取消時(即結束了)返回一個關閉的channel // 即如果當前Context超時或取消時,Done方法會返回一個channel,然後其他地方就可以通過判斷Done方法是否有返回(channel),如果有則說明Context已結束 // 故其可以作為廣播通知其他相關方本Context已結束,請做相關處理。 Done() <-chan struct{} // 返回Context取消的原因 Err() error // 返回Context相關資料 Value(key interface{}) interface{} }
那麼到底什麼Context?
可以字面意思可以理解為上下文,比較熟悉的有程序/執行緒上線文,關於Golang中的上下文,一句話概括就是:goroutine的相關環境快照,其中包含函式呼叫以及涉及的相關的變數值。
通過Context可以區分不同的goroutine請求,因為在Golang Severs中,每個請求都是在單個goroutine中完成的。
注:關於goroutine的理解可以移步ofollow,noindex" target="_blank">這裡 。
2、Why Context
由於在Golang severs中,每個request都是在單個goroutine中完成,並且在單個goroutine(不妨稱之為A)中也會有請求其他服務(啟動另一個goroutine(稱之為B)去完成)的場景,這就會涉及多個Goroutine之間的呼叫。如果某一時刻請求其他服務被取消或者超時,則作為深陷其中的當前goroutine B需要立即退出,然後系統才可回收B所佔用的資源。
即一個request中通常包含多個goroutine,這些goroutine之間通常會有互動。
那麼,如何有效管理這些goroutine成為一個問題(主要是退出通知和元資料傳遞問題),Google的解決方法是Context機制,相互呼叫的goroutine之間通過傳遞context變數保持關聯,這樣在不用暴露各goroutine內部實現細節的前提下,有效地控制各goroutine的執行。
如此一來,通過傳遞Context就可以追蹤goroutine呼叫樹,並在這些呼叫樹之間傳遞通知和元資料。
雖然goroutine之間是平行的,沒有繼承關係,但是Context設計成是包含父子關係的,這樣可以更好的描述goroutine呼叫之間的樹型關係。
3、How to use
生成一個Context主要有兩類方法:
3.1 )頂層Context:Background
要建立Context樹,首先就是要建立根節點
// 返回一個空的Context,它作為所有由此繼承Context的根節點 func Background() Context
該Context通常由接收request的第一個goroutine建立,它不能被取消、沒有值、也沒有過期時間,常作為處理request的頂層context存在。
3.2)下層Context:WithCancel/WithDeadline/WithTimeout
有了根節點之後,接下來就是建立子孫節點。為了可以很好的控制子孫節點,Context包提供的建立方法均是帶有第二返回值(CancelFunc型別),它相當於一個Hook,在子goroutine執行過程中,可以通過觸發Hook來達到控制子goroutine的目的(通常是取消,即讓其停下來)。再配合Context提供的Done方法,子goroutine可以檢查自身是否被父級節點Cancel:
select { case <-ctx.Done(): // do some clean… }
注:父節點Context可以主動通過呼叫cancel方法取消子節點Context,而子節點Context只能被動等待。同時父節點Context自身一旦被取消(如其上級節點Cancel),其下的所有子節點Context均會自動被取消。
有三種建立方法:
// 帶cancel返回值的Context,一旦cancel被呼叫,即取消該建立的context func WithCancel(parent Context) (ctx Context, cancel CancelFunc) // 帶有效期cancel返回值的Context,即必須到達指定時間點呼叫的cacel方法才會被執行 func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) // 帶超時時間cancel返回值的Context,類似Deadline,前者是時間點,後者為時間間隔 // 相當於WithDeadline(parent, time.Now().Add(timeout)). func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
下面來看改編自Advanced Go Concurrency Patterns視訊提供的一個簡單例子:
package main import ( "context" "fmt" "time" ) func someHandler() { // 建立繼承Background的子節點Context ctx, cancel := context.WithCancel(context.Background()) go doSth(ctx) //模擬程式執行 - Sleep 5秒 time.Sleep(5 * time.Second) cancel() } //每1秒work一下,同時會判斷ctx是否被取消,如果是就退出 func doSth(ctx context.Context) { var i = 1 for { time.Sleep(1 * time.Second) select { case <-ctx.Done(): fmt.Println("done") return default: fmt.Printf("work %d seconds: \n", i) } i++ } } func main() { fmt.Println("start...") someHandler() fmt.Println("end.") }
輸出結果:
注意,此時doSth方法中case之done的fmt.Println("done")
並沒有被打印出來。
超時場景:
package main import ( "context" "fmt" "time" ) func timeoutHandler() { // 建立繼承Background的子節點Context ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) go doSth(ctx) //模擬程式執行 - Sleep 10秒 time.Sleep(10 * time.Second) cancel() // 3秒後將提前取消 doSth goroutine } //每1秒work一下,同時會判斷ctx是否被取消,如果是就退出 func doSth(ctx context.Context) { var i = 1 for { time.Sleep(1 * time.Second) select { case <-ctx.Done(): fmt.Println("done") return default: fmt.Printf("work %d seconds: \n", i) } i++ } } func main() { fmt.Println("start...") timeoutHandler() fmt.Println("end.") }
輸出結果:
4、Really elegant solution?
前面鋪地了這麼多。
確實,通過引入Context包,一個request範圍內所有goroutine執行時的取消可以得到有效的控制。但是這種解決方式卻不夠優雅。
4.1 Like a virus
一旦程式碼中某處用到了Context,傳遞Context變數(通常作為函式的第一個引數)會像病毒一樣蔓延在各處呼叫它的地方。比如在一個request中實現資料庫事務或者分散式日誌記錄,建立的context,會作為引數傳遞到任何有資料庫操作或日誌記錄需求的函式程式碼處。即每一個相關函式都必須增加一個context.Context型別的引數,且作為第一個引數,這對無關程式碼完全是侵入式的。
更多詳細內容可參見:Michal Strba 的context-should-go-away-go2 文章
Google Group上的討論可移步這裡 。
4.2 Context isn’t for cancellation
Context機制最核心的功能是在goroutine之間傳遞cancel訊號,但是它的實現是不完全的。
Cancel可以細分為主動與被動兩種,通過傳遞context引數,讓呼叫goroutine可以主動cancel被呼叫goroutine。但是如何得知被呼叫goroutine什麼時候執行完畢,這部分Context機制是沒有實現的。而現實中的確又有一些這樣的場景,比如一個組裝資料的goroutine必須等待其他goroutine完成才可開始執行,這是context明顯不夠用了,必須藉助sync.WaitGroup。
func serve(l net.Listener) error { var wg sync.WaitGroup var conn net.Conn var err error for { conn, err = l.Accept() if err != nil { break } wg.Add(1) go func(c net.Conn) { defer wg.Done() handle(c) }(conn) } wg.Wait() return err }
4.3 context.value
context.Value相當於goroutine的TLS(Thread Local Storage),但它不是靜態型別安全的,任何結構體變數都必須作為字串形式儲存。同時,所有context都會在其中定義變數,很容易造成命名衝突。