什麼是Context
最近在公司分析gRPC源碼,proto檔案生成的代碼,接口函數第一個參數統一是ctx context.Context接口,公司不少同僚都不了解這樣設計的出發點是什麼,其實我也不了解其背後的原理。今天趁着妮妲台風妹子正面登陸深圳,全市停工、停課、停業,在家休息找了一些資料研究把玩一把。
Context通常被譯作上下文,它是一個比較抽象的概念。在公司技術讨論時也經常會提到上下文。一般了解為程式單元的一個運作狀态、現場、快照,而翻譯中上下又很好地诠釋了其本質,上下上下則是存在上下層的傳遞,上會把内容傳遞給下。在Go語言中,程式單元也就指的是Goroutine。
每個Goroutine在執行之前,都要先知道程式目前的執行狀态,通常将這些執行狀态封裝在一個Context變量中,傳遞給要執行的Goroutine中。上下文則幾乎已經成為傳遞與請求同生存周期變量的标準方法。在網絡程式設計下,當接收到一個網絡請求Request,處理Request時,我們可能需要開啟不同的Goroutine來擷取資料與邏輯處理,即一個請求Request,會在多個Goroutine中處理。而這些Goroutine可能需要共享Request的一些資訊;同時當Request被取消或者逾時的時候,所有從這個Request建立的所有Goroutine也應該被結束。
context包
Go的設計者早考慮多個Goroutine共享資料,以及多Goroutine管理機制。Context介紹請參考Go Concurrency Patterns: Context,golang.org/x/net/conte…包就是這種機制的實作。
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接口,其定義如下:
scss複制代碼type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
- Deadline會傳回一個逾時時間,Goroutine獲得了逾時時間後,例如可以對某些io操作設定逾時時間。
- Done方法傳回一個信道(channel),當Context被撤銷或過期時,該信道是關閉的,即它是一個表示Context是否已關閉的信号。
- 當Done信道關閉後,Err方法表明Context被撤的原因。
- Value可以讓Goroutine共享一些資料,當然獲得資料是協程安全的。但使用這些資料的時候要注意同步,比如傳回了一個map,而這個map的讀寫則要加鎖。
Context接口沒有提供方法來設定其值和過期時間,也沒有提供方法直接将其自身撤銷。也就是說,Context不能改變和撤銷其自身。那麼該怎麼通過Context傳遞改變後的狀态呢?
context使用
無論是Goroutine,他們的建立和調用關系總是像層層調用進行的,就像人的輩分一樣,而更靠頂部的Goroutine應有辦法主動關閉其下屬的Goroutine的執行(不然程式可能就失控了)。為了實作這種關系,Context結構也應該像一棵樹,葉子節點須總是由根節點衍生出來的。
要建立Context樹,第一步就是要得到根節點,context.Background函數的傳回值就是根節點:
go複制代碼func Background() Context
該函數傳回空的Context,該Context一般由接收請求的第一個Goroutine建立,是與進入請求對應的Context根節點,它不能被取消、沒有值、也沒有過期時間。它常常作為處理Request的頂層context存在。
有了根節點,又該怎麼建立其它的子節點,孫節點呢?context包為我們提供了多個函數來建立他們:
scss複制代碼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函數類型變量,該函數類型的定義為:
go複制代碼type CancelFunc func()
調用CancelFunc對象将撤銷對應的Context對象,這就是主動撤銷Context的方法。在父節點的Context所對應的環境中,通過WithCancel函數不僅可建立子節點的Context,同時也獲得了該節點Context的控制權,一旦執行該函數,則該節點Context就結束了,則子節點需要類似如下代碼來判斷是否已結束,并退出該Goroutine:
csharp複制代碼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,以及傳遞一些變量來共享。
- Context對象的生存周期一般僅為一個請求的處理周期。即針對一個請求建立一個Context變量(它為Context樹結構的根);在請求處理結束後,撤銷此ctx變量,釋放資源。
- 每次建立一個Goroutine,要麼将原有的Context傳遞給Goroutine,要麼建立一個子Context并傳遞給Goroutine。
- Context能靈活地存儲不同類型、不同數目的值,并且使多個Goroutine安全地讀寫其中的值。
- 當通過父Context對象建立子Context對象時,可同時獲得子Context的一個撤銷函數,這樣父Context對象的建立環境就獲得了對子Context将要被傳遞到的Goroutine的撤銷權。
- 在子Context被傳遞到的goroutine中,應該對該子Context的Done信道(channel)進行監控,一旦該信道被關閉(即上層運作環境撤銷了本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的程式包需要遵循如下的原則來滿足接口的一緻性以及便于靜态分析。
- Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx;不要把Context存在一個結構體當中,顯式地傳入函數。Context變量需要作為第一個參數使用,一般命名為ctx;
- Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use;即使方法允許,也不要傳入一個nil的Context,如果你不确定你要用什麼Context的時候傳一個context.TODO;
- Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions;使用context的Value相關方法隻應該用于在程式和接口中傳遞的和請求相關的中繼資料,不要用它來傳遞一些可選的參數;
- The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines;同樣的Context可以用來傳遞到不同的goroutine中,Context在多個goroutine中是安全的;
連結:https://juejin.cn/post/6844903447771234317