最近在公司分析gRPC原始碼,proto檔案生成的程式碼,介面函數第一個參數統一是ctx context.Context介面,公司不少同事都不瞭解這樣設計的出發點是什麼,其實我也不瞭解其背後的原理。今天趁着妮妲颱風妹子正面登陸深圳,全市停工、停課、停業,在家休息找了一些資料研究把玩一把。
Context通常被譯作上下文,它是一個比較抽象的概念。在公司技術討論時也經常會提到上下文。一般理解爲程式單元的一個執行狀態、現場、快照,而翻譯中上下又很好地詮釋了其本質,上下上下則是存在上下層的傳遞,上會把內容傳遞給下。在Go語言中,程式單元也就指的是Goroutine。
每個Goroutine在執行之前,都要先知道程式當前的執行狀態,通常將這些執行狀態封裝在一個Context變數中,傳遞給要執行的Goroutine中。上下文則幾乎已經成爲傳遞與請求同生存週期變數的標準方法。在網路程式設計下,當接收到一個網路請求Request,處理Request時,我們可能需要開啓不同的Goroutine來獲取數據與邏輯處理,即一個請求Request,會在多個Goroutine中處理。而這些Goroutine可能需要共用Request的一些資訊;同時當Request被取消或者超時的時候,所有從這個Request建立的所有Goroutine也應該被結束。
Go的設計者早考慮多個Goroutine共用數據,以及多Goroutine管理機制 機製。Context介紹請參考Go Concurrency Patterns: Context,golang.org/x/net/context包就是這種機制 機製的實現。
context包不僅實現了在程式單元之間共用狀態變數的方法,同時能通過簡單的方法,使我們在被呼叫程式單元的外部,通過設定ctx變數值,將過期或復原這些信號傳遞給被呼叫的程式單元。在網路程式設計中,若存在A呼叫B的API, B再呼叫C的API,若A呼叫B取消,那也要取消B呼叫C,通過在A,B,C的API呼叫之間傳遞Context,以及判斷其狀態,就能解決此問題,這是爲什麼gRPC的介面中帶上ctx context.Context參數的原因之一。
Go1.7(當前是RC2版本)已將原來的golang.org/x/net/context包挪入了標準庫中,放在$GOROOT/src/context下面 下麪。標準庫中net、net/http、os/exec都用到了context。同時爲了考慮相容,在原golang.org/x/net/context包下存在兩個檔案,go17.go是呼叫標準庫的context包,而pre_go17.go則是之前的預設實現,其介紹請參考go程式包原始碼解讀。
context包的核心就是Context介面,其定義如下:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
無論是Goroutine,他們的建立和呼叫關係總是像層層呼叫進行的,就像人的輩分一樣,而更靠頂部的Goroutine應有辦法主動關閉其下屬的Goroutine的執行(不然程式可能就失控了)。爲了實現這種關係,Context結構也應該像一棵樹,葉子節點须總是由根節點衍生出來的。
要建立Context樹,第一步就是要得到根節點,context.Background函數的返回值就是根節點:
func Background() Context
該函數返回空的Context,該Context一般由接收請求的第一個Goroutine建立,是與進入請求對應的Context根節點,它不能被取消、沒有值、也沒有過期時間。它常常作爲處理Request的頂層context存在。
有了根節點,又該怎麼建立其它的子節點,孫節點呢?context包爲我們提供了多個函數來建立他們:
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
函數都接收一個Context型別的參數parent,並返回一個Context型別的值,這樣就層層建立出不同的節點。子節點是從複製父節點得到的,並且根據接收參數設定子節點的一些狀態值,接着就可以將子節點傳遞給下層的Goroutine了。
再回到之前的問題:該怎麼通過Context傳遞改變後的狀態呢?使用Context的Goroutine無法取消某個操作,其實這也是符合常理的,因爲這些Goroutine是被某個父Goroutine建立的,而理應只有父Goroutine可以取消操作。在父Goroutine中可以通過WithCancel方法獲得一個cancel方法,從而獲得cancel的權利。
第一個WithCancel函數,它是將父節點複製到子節點,並且還返回一個額外的CancelFunc函數型別變數,該函數型別的定義爲:
type CancelFunc func()
呼叫CancelFunc物件將復原對應的Context物件,這就是主動復原Context的方法。在父節點的Context所對應的環境中,通過WithCancel函數不僅可建立子節點的Context,同時也獲得了該節點Context的控制權,一旦執行該函數,則該節點Context就結束了,則子節點需要類似如下程式碼來判斷是否已結束,並退出該Goroutine:
select {
case <-cxt.Done():
// do some clean...
}
WithDeadline函數的作用也差不多,它返回的Context型別值同樣是parent的副本,但其過期時間由deadline和parent的過期時間共同決定。當parent的過期時間早於傳入的deadline時間時,返回的過期時間應與parent相同。父節點過期時,其所有的子孫節點必須同時關閉;反之,返回的父節點的過期時間則爲deadline。
WithTimeout函數與WithDeadline類似,只不過它傳入的是從現在開始Context剩餘的生命時長。他們都同樣也都返回了所建立的子Context的控制權,一個CancelFunc型別的函數變數。
當頂層的Request請求函數結束後,我們就可以cancel掉某個context,從而層層Goroutine根據判斷cxt.Done()來結束。
WithValue函數,它返回parent的一個副本,呼叫該副本的Value(key)方法將得到val。這樣我們不光將根節點原有的值保留了,還在子孫節點中加入了新的值,注意若存在Key相同,則會被覆蓋。
context包通過構建樹型關係的Context,來達到上一層Goroutine能對傳遞給下一層Goroutine的控制。對於處理一個Request請求操作,需要採用context來層層控制Goroutine,以及傳遞一些變數來共用。
Programs that use Contexts should follow these rules to keep interfaces consistent across packages and enable static analysis tools to check context propagation:
使用Context的程式包需要遵循如下的原則來滿足介面的一致性以及便於靜態分析。