1. 程式人生 > 其它 >Golang context.Context介紹

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()依然會無限迴圈導致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)

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

但是官網明確說明WithValue的key不應該是字串或任何其他內建型別,以免與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)
	f(ctx, favContextKey("color"))
}

三、總結:

一般來說當一個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傳遞更豐富的訊息,有待挖掘補充。

想建一個數據庫技術和程式設計技術的交流群,用於磨鍊提升技術能力,目前主要專注於Golang和Python以及TiDB,MySQL資料庫,群號:231338927,建群日期:2019.04.26,截止2021.02.01人數:300人 ... 如發現部落格錯誤,可直接留言指正,感謝。