Golang context.Context介紹


近日某公眾號連推2篇關於context的文章,圖文不符的錯誤多處,也不適合我理解,因此查看官方文檔后總結一篇筆記。

context package - context - pkg.go.dev 

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}

本文不會直接講述context的設計初衷和由來,也不會直接講述context相比於其他並發控制方式的優劣。

本文旨在通過解析context包官方文檔和示例來探明context的使用方式,從而反推其使用場景。

這里先貼一下官網文檔的Overview部分的簡單直譯。這部分內容描述了context包的使用方式、主要結構、使用場景,當寫完整篇筆記后再來印證可以更深刻理解。

一、Overview部分直譯,為便於理解有刪減,完整內容查看官網鏈接:

context包提供了Context類型,這種類型可以承載deadlines、取消信號等可以在API邊界和進程之間傳遞消息的對象。

向server發出請求時應當創建一個context,server處理呼叫應當接收context。整個調用鏈中context應當作為參數被處理函數傳遞。這些context可以是也最好是WithCancel, WithDeadline, WithTimeout 或者 WithValue這些衍生出來的child context。當一個Context被取消時,他的child context也會取消。

WithCancel, WithDeadline, WithTimeout這幾個函數接收一個父Context對象,返回子Context對象和一個CancelFunc。當調用對應的CancelFunc時,對應的子Context對象就會被取消。調用CancelFunc失敗則child context就會泄露直到父context被取消或者自身超時。

使用context的程序應當遵循以下規則,以便允許靜態分析工具可以獲知context的傳播鏈路:

1. 不要在struct type中存儲context,而應當將其作為函數的參數進行傳遞,即想要使用context時應該給函數額外加一個ctx的參數。

func DoSomething(ctx context.Context, arg Arg) error {
	// ... use ctx ...
}

2. 永遠不要傳遞nil context,如果你不確定該使用哪種context,那么可以先傳個context.TODO替代。

3. Values不應當用作業務參數的傳遞(雖然這么做確實可以),而應當用來在APIs、processes之間傳遞消息。

4. 多個goroutine函數可以共用Context, context是並發安全的。

可以通過此地址查看示例:Go Concurrency Patterns: Context - go.dev,獲知server是如何使用context傳遞消息的。

二、context包提供了四種child context使用示例:

Context接口並不需要我們自己實現,context包已經提供了2個函數(context.Background()和context.TODO())來返回空Context類型(兩者本質上一模一樣,名字不同只是為了體現不同場景下的差異),並提供了4個With開頭函數來生成具有特定功能的child Context。

  • WithCancel
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

 WithCancel返回一個child Context ctx,相比於輸入的parent,其重寫了Done(),實現的功能是:當CancelFunc被調用或parent的Done被寫入時,ctx的Done channel會被寫入(struct{}{}的空消息),使用ctx的goroutine就可以通過讀取ctx.Done()來獲知取消信號了。

package main

import (
	"context"
	"fmt"
)

func main() {
    // gen是一個函數,用於不斷的返回整數數字,因為是返回的是只讀的unbuffered channel,因此只有當返回值被消費時才會繼續返回下一個
	gen := func(ctx context.Context) <-chan int {
		dst := make(chan int)
		n := 1
		go func() {
            // 在gen內部,通過for select不斷的檢查ctx.Done()信息來確定自己是否需要return,未接收到ctx.Done()消息時就返回數字等待被消費
			for {
				select {
				case <-ctx.Done():
					return // 當從ctx.Done()接收到消息時return函數,防止泄露
				case dst <- n:
					n++
				}
			}
		}()
		return dst
	}
	// 在gen外部使用WithCancel創建一個ctx,可以看到其parent是context.Background(),context.Background()返回一個非nil的空Context
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel() // 當主函數退出時執行cancel函數,此時ctx.Done() channel就會被寫入,從而使gen退出

    // 主函數遍歷gen()的輸出,當n=5時break循環,break之后defer cancel()觸發,之后上述case <-ctx.Done():被觸發從而退出gen函數
	for n := range gen(ctx) {
		fmt.Println(n)
		if n == 5 {
			break
		}
	}
    // 試想下如果沒有context會怎樣: 當main函數for n := range gen(ctx) break之后,gen()會卡在n=6上無限阻塞而不會釋放,這就是goroutine泄露(當然在本例中並不會,因為main函數執行完之后整個進程就退出了)
  •  WithDeadline
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

WithDeadline返回一個child Context ctx,相比於輸入的parent,其deadline不晚於指定的d時刻。如果parent的deadline比d更早,那么按parent的deadline來算。

什么情況下ctx的Done channel會有消息:1. 當到達deadline時刻 2.CancelFunc被調用 3.parent的Done channel被寫入。

package main

import (
	"context"
	"fmt"
	"time"
)

const shortDuration = 1 * time.Millisecond  

func main() {
	d := time.Now().Add(shortDuration) // 定義一個基於當前時間1ms后的時刻:d
	ctx, cancel := context.WithDeadline(context.Background(), d)  // 將d作為ctx超時的時刻
	defer cancel() // 當main goroutine結束時調用cancel函數

	select {
	case <-time.After(1 * time.Second):
		fmt.Println("overslept")
	case <-ctx.Done():
		fmt.Println(ctx.Err())
	}
	// 檢查time.After(1 * time.Second)和ctx.Done()哪個channel有消息,都沒消息就阻塞於此一直檢查,發現一個有消息就執行對應邏輯然后執行defer cancel()
    // 主函數直到結束才會調用cancel(),time.After(1 * time.Second)時長高達1s,而deadline時刻只有1ms的長度,所以在1ms后ctx.Done()就會因為deadline到達而被寫入,因此這個select會在1ms后就直接接收到ctx.Done()消息,然后執行fmt.Println(ctx.Err()),打印出錯誤:context deadline exceeded。
    // 在這之后繼續執行defer cancel()會繼續給ctx.Done() channel發消息,那會遇到send on closed channel的panic嗎?不會,Done()的返回是冪等的:Successive calls to Done return the same value.。
    // 既然ctx的Done()會因為deadline到達而被提前寫入消息,那還有必要defer cancel()嗎?官網的解釋是有必要,因為這可以確保ctx及其父context的釋放。
}
  • WithTimeout
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithTimeout returns WithDeadline(parent, time.Now().Add(timeout)).

除了這句無需解釋了,WithTimeout就是WithDeadline的一個簡易入口,使我們可以直接定義ctx多長時間超時,省了自己time.Now().Add(timeout)的步驟。

特別需要注意的一點:WithTimeout Context是在Context創建后就開始計時的,而非對應的函數調用后,因此WithTimeout context應當在函數調用前定義最好。

  • WithValue
func WithValue(parent Context, key, val interface{}) Context

WithValue相比於之前的3個With開頭的函數有區別,他不會返回CancelFunc函數,可以為Context.Value傳值。

官網明確說明WithValue的key應該是可比較的(否則panic),且不應該是字符串或任何其他內置類型,也不應當是可以被其他包訪問的類型,建議傳遞一個包內的自定義類型,以免與其他使用context的包產生沖突。

直接示例之,下例展示了如何通過context傳值以及如何在函數內取到值並做判斷:

package main

import (
	"context"
	"fmt"
)

func main() {
	type favContextKey string  // 自定義一個string的類型:favContextKey

    // 定義函數f,接收ctx和k參數
	f := func(ctx context.Context, k favContextKey) {
        // 獲取ctx中存儲的key-value pair,如果匹配到了輸入參數k對應的值,則把值存入v中並打印
		if v := ctx.Value(k); v != nil {
			fmt.Println("found value:", v)
			return
		}
        // 如果在ctx中未匹配到k對應的value,那么打印未找到信息
		fmt.Println("key not found:", k)
	}

	k := favContextKey("language")
	ctx := context.WithValue(context.Background(), k, "Go")
    // WithValue返回的ctx存儲了一個key-value pair,其key為k,value為Go

	f(ctx, k) // 執行此函數時會檢查給定的key參數是否與ctx中存儲的key是否匹配,這里用的同一個變量當然匹配,因此會報found value
	f(ctx, favContextKey("color")) // 此函數執行時因為color並不是ctx中存儲的key的值:language,因此會報key not found
}
// WithValue的適用場景很少,屬於那種除非有實際需要,否則完全不必去主動了解的內容,待有實際需求場景時你自然就會想到WithValue ctx了。

三、總結:

一般來說當一個goroutine啟動之后我們就很難控制他的運行了,除非預先定義了一個channel,然后在goroutine內部不斷的檢查channel的消息來決定后繼運行邏輯。

基於此邏輯我們來總結context的使用:

通過上述3個示例,我們可以看到整個context包其實就是圍繞Context.Done()這個channel來做文章的。無論是CancelFunc還是Deadline(),Err(),其目的都是輔助Done(),目的就是當滿足某些條件時給Done channel傳遞消息,在goroutine內部則使用select檢查ctx.Done()是否有消息來決定下一步的執行邏輯。

context包提供了一個更人性化的channel定義方式,免了開發者自定義各種通信channel的煩惱。

與sync.WaitGroup的區別何在?

很明顯的wg用於等到本組內的goroutine自然終結,而context提供了主動終結goroutine的能力,雖然這種能力是建立在需要goroutine內部檢查ctx相關狀態的基礎上的。

最后WithValue的使用與其他幾個有很大區別,看起來更加的靈活,可以為goroutine傳遞更豐富的消息,有待挖掘補充。

一般來說,我們很少會有必要去自己實現context相關的對象,通常的場景是這樣的:

一些第三方庫或開源庫的作者為了讓自己的庫變得更加人性化(如提供執行超時退出的功能),會為自己的函數加一個ctx參數,然后在函數內部不斷地檢查ctx.Done()的消息以便判斷函數是否要退出。

而我們作為使用者,只需要先根據自己的需要構造一個Conext然后作為參數傳給這個函數即可,select判斷的邏輯根本不需要我們自己做,我們只需要關注一點:“構造自己需要的context”

例如我們想要函數超過5s自動報錯,那就這樣構造一個context傳進去:

ctx, cancelFunc := context.WithTimeout(context.Background(), 5 * time.Second)
deffer cancelFunc()  // 無論ctx是否觸發超時都要記得主動關閉cancelFunc,這是個好習慣
...
// 假設第三方函數格式類似如下:
execute(ctx context.Context, args ...interface{})
...

這樣看和傳入一個timeout參數,然后通過循環計數+1s來判斷執行是否超時也是可以的,但是golang不是提供了time.After的channel嘛,用channel來判斷更好些。

更重要的是替代傳參timeout只是context的一種優勢場景,我們還有其他context可以構造,並且使用統一的context包可以極大的簡化ctx接受者的工作量,開源庫作者只需要接收ctx然后判斷即可,使用者只需要建造ctx然后傳參即可,

大家在同一套框架下愉快的和諧共處~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM