在併發程式中,由於連接超時、使用者取消或系統故障,往往需要執行搶佔操作。過去,我們利用 done 通道在程式中取消所有阻塞的並發操作,儘管這方法有其效用,但確實也有其局限性。
假若能在取消通知中加入額外的資訊,例如:取消原因、操作是否正常完成等,這將對我們進一步的處理大有幫助。於是,在社區的推動下,Go 開發組決定建立一個標準模式來應對這類需求。因此,在 Go 1.7 版本中,context (上下文)包被納入標準庫。
其中有一個 Done 方法,它返回一個通道,當我們的函式被搶佔時該通道會被關閉。除此之外,還有幾個易於理解的方法:一個 Deadline 函式,用於指示在特定時間後,是否會取消 goroutine;還有一個 Err 方法,若 goroutine 被取消,則會返回非零值。但其中的 Value 方法看起來有些特別,那它的用途是什麼呢?
goroutines 的主要用途之一是為請求提供服務。通常,在這些程式中,除了搶佔訊息之外,還需要傳遞特定於請求的資訊。這正是 Value 函數存在的意義。此處我們只需了解 context 包的兩大主要目的:一是提供取消操作,二是提供用於透過呼叫傳輸請求附加資料的數據包。
接下來讓我們探討第一個目的:取消操作。正如我們在“防止Goroutine泄漏”中所學,函式中的取消有三個方面:
goroutine 的產生者可能希望取消它。
goroutine 可能需要取消其派生出來的 goroutine。
在 goroutine 中的任何阻塞操作都必須是可搶佔的,以利於取消。
Context 包能幫助我們處理上述三個方面的需求。
如先前提到,Context 類型將會是函式的第一個參數。若你查看了 Context 接口的方法,會發現裡面沒有任何方法能改變其內部狀態。更精確地說,系統不允許修改 Context 本身。這確保了 Context 調用堆疊的功能。因此,結合接口中的 Done 方法,Context 類型可以安全地管理取消操作。
這引起了一個問題:如果 Context 是不變的,那麼我們如何影響調用堆疊中當前函式的子函式的取消行為?
你會發現,這些函式都會接受並返回 Context 類型的值,並且使用相關的選項生成 Context 的新實例: WithCancel、 WithDeadline、以及 WithTimeout。
如果你的函式需要在某些情境中取消它的子函式,你可以調用上述三個函式中的任一個並傳遞給它的上下文,然後將返回的上下文傳遞給子函式。如果你的函式不需要改變取消行為,那麼只需傳遞給定的上下文即可。
透過這樣的方式,調用者可以根據需求創建 Context,而不會影響其原始版本。這為管理調用分支提供了一個可組合且優雅的解決方案。
而在非同步調用鏈的頂部,你的程式碼可能不會傳遞 Context。為了開始這些調用鏈,context 包提供了兩個函式來創建 Context 的空實例。
要注意的是,在物件導向的範疇中,通常會將經常使用的數據的引用存為成員變量,但在 context.Context 的實例上執行此操作是不建議的。儘管 context.Context 的實例對外部可能看起來相同,但在內部它們可能會隨著每個堆疊框架而有所不同。因此,總是將 Context 的實例傳遞給你的函式是非常重要的。
func main() {
var wg sync.WaitGroup
done := make(chan interface{})
defer close(done)
wg.Add(1)
go func() {
defer wg.Done()
if err := printGreeting(done); err != nil {
fmt.Printf("%v", err)
return
}
}()
wg.Add(1)
go func() {
defer wg.Done()
if err := printFarewell(done); err != nil {
fmt.Printf("%v", err)
return
}
}()
wg.Wait()
}
func printGreeting(done <-chan interface{}) error {
greeting, err := genGreeting(done)
if err != nil {
return err
}
fmt.Printf("%s world!\n", greeting)
return nil
}
func printFarewell(done <-chan interface{}) error {
farewell, err := genFarewell(done)
if err != nil {
return err
}
fmt.Printf("%s world!\n", farewell)
return nil
}
func genGreeting(done <-chan interface{}) (string, error) {
switch locale, err := locale(done); {
case err != nil:
return "", err
case locale == "EN/US":
return "hello", nil
}
return "", fmt.Errorf("unsupported locale")
}
func genFarewell(done <-chan interface{}) (string, error) {
switch locale, err := locale(done); {
case err != nil:
return "", err
case locale == "EN/US":
return "goodbye", nil
}
return "", fmt.Errorf("unsupported locale")
}
func locale(done <-chan interface{}) (string, error) {
select {
case <-done:
return "", fmt.Errorf("canceled")
case <-time.After(1 * time.Minute):
}
return "EN/US", nil
}
輸出:
hello world!
goodbye world!
忽略競爭條件,我們可以看到程序有兩個分支同時運行。通過創建done通道並將其傳遞給我們的調用鏈 來設置標準搶占方法。如果我們在main的任何一點關閉done頻道,那麽兩個分支都將被取消。
我們可以嘗試幾種不同且有趣的方式來控制該程序。也許我們希望genGreeting如果花費太長時間就會超 時。也許我們不希望genFarewell調用locale——在其父進程很快就會被取消的情況下。在每個堆棧框架中,一個函數可以影響其下的整個調用堆棧。
使用done通道模式,我們可以通過將傳入的done通道包裝到其他done通道中,然後在其中任何一個通道啟動時返回,但我們不會獲得上下文給的deadline和錯誤的額外信息。
讓我們使用context包來修改該程序。由於現在可以使用context.Context的靈活性,所以我們引入一個有趣的場景。
假設genGreeting在放棄調用locale之前等待一秒——超時時間為1秒。
如果printGreeting不成功,我們想取消對printFare的調用。
畢竟,如果我們不打聲招呼,說再見就沒有意義了:
func main() {
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(context.Background()) // <1> 使用context.Background()在main函數中創建一個新的Context,並使用context.WithCancel將其包裹以便對其執行取消操作。
defer cancel()
wg.Add(1)
go func() {
defer wg.Done()
if err := printGreeting(ctx); err != nil {
fmt.Printf("cannot print greeting: %v\n", err)
cancel() // <2> 在這一行,如果從 printGreeting返回錯誤,main將取消context。
}
}()
wg.Add(1)
go func() {
defer wg.Done()
if err := printFarewell(ctx); err != nil {
fmt.Printf("cannot print farewell: %v\n", err)
}
}()
wg.Wait()
}
func printGreeting(ctx context.Context) error {
greeting, err := genGreeting(ctx)
if err != nil {
return err
}
fmt.Printf("%s world!\n", greeting)
return nil
}
func printFarewell(ctx context.Context) error {
farewell, err := genFarewell(ctx)
if err != nil {
return err
}
fmt.Printf("%s world!\n", farewell)
return nil
}
func genGreeting(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, 1*time.Second) // <3> 這裡genGreeting用context.WithTimeout包裝Context。這將在1秒後自動取消返回的context,從而取消它傳遞context的子進程,即語言環境。
defer cancel()
switch locale, err := locale(ctx); {
case err != nil:
return "", err
case locale == "EN/US":
return "hello", nil
}
return "", fmt.Errorf("unsupported locale")
}
func genFarewell(ctx context.Context) (string, error) {
switch locale, err := locale(ctx); {
case err != nil:
return "", err
case locale == "EN/US":
return "goodbye", nil
}
return "", fmt.Errorf("unsupported locale")
}
func locale(ctx context.Context) (string, error) {
select {
case <-ctx.Done():
return "", ctx.Err() // <4> 這一行返回為什麼Context被取消的原因。 這個錯誤會一直冒泡到main,這會導致注釋2處的取消操作被調用。
case <-time.After(1 * time.Minute):
}
return "EN/US", nil
}
輸出:
cannot print greeting: context deadline exceeded
cannot print farewell: context canceled
我們可以看到系統輸出工作正常。由於local設置至少需要運行一分鐘,因此genGreeting將始終超時,這意味著main會始終取消printFarewell下面的調用鏈。
請注意,genGreeting如何構建自定義的Context.Context以滿足其需求,而不必影響父級的Context。
如果genGreeting成功返回,並且printGreeting需要再次調用,則可以在不泄漏genGreeting相關操作信息的情況下進行。
這種可組合性使你能夠編寫大型系統,而無需在整個調用鏈中費勁心思解決這樣的問題。