iT邦幫忙

2023 iThome 鐵人賽

DAY 22
0

在併發程式中,由於連接超時、使用者取消或系統故障,往往需要執行搶佔操作。過去,我們利用 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相關操作信息的情況下進行。
這種可組合性使你能夠編寫大型系統,而無需在整個調用鏈中費勁心思解決這樣的問題。


上一篇
20.Queue
下一篇
23.Error-propagation
系列文
Concurrency in go 讀書心得30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言