聊一聊Go的Context上下文

Context

前言

前面在“聊一聊http框架httprouter”的時候,有提到上下文的概念,上一個demo用來列舉web框架中全局變量的傳值和設置,還類比了Java Spring框架中ApplicationContext
這一次我們就來聊一聊Go中的標準庫的context,梳理上下文概念在go中的常用情景。

問題引入

在列舉上下文的用法之前,我們來看一個簡單的示例:

協程泄露

func main()  {
	//打印已有協程數量
	fmt.Println("Start with goroutines num:", runtime.NumGoroutine())
	//新起子協程
	go Spawn()
	time.Sleep(time.Second)
	fmt.Println("Before finished, goroutines num:", runtime.NumGoroutine())
	fmt.Println("Main routines exit!")
}

func Spawn()  {
	count := 1
	for {
		time.Sleep(100 * time.Millisecond)
		count++
	}
}

輸出:

Start with goroutines num: 1
Before finished, goroutines num: 2
Main routines exit!

我們在主協程創建一個子協程,利用runtime.NumGoroutine()打印當前協程數量,可以知道在main協程被喚醒之後退出前的那一刻,程序中仍然存在兩個協程,可能我們已經習慣了這種現象,殺掉主協程的時候,退出會附帶把子協程幹掉,這種讓子協程“自生自滅”的做法,其實是不太優雅的。

解決方式

管道通知退出

關於控制子協程的退出,可能有人會想到另一個做法,我們再來看另一個例子。

func main()  {
	defer fmt.Println("Main routines exit!")
	ExitBySignal()
	fmt.Println("Start with goroutines num:", runtime.NumGoroutine())
	//主動通知協程退出
	sig <- true
	fmt.Println("Before finished, goroutines num:", runtime.NumGoroutine())
}

//利用管道通知協程退出
func ListenWithSignal()  {
	count := 1
	for {
		select {
		//監聽通知
		case <-sig:
			return
		default:
			//正常執行
			time.Sleep(100 * time.Millisecond)
			count++
		}
	}
}

輸出:

Start with goroutines num: 2
Before finished, goroutines num: 1
Main routines exit!

上面這個例子可以說相對優雅一些,在main協程裏面主動通知子協程退出,不過兩者之間的仍然存在依賴,假如子協程A又創建了新的協程B,這個時候通知只能到達子A,新協程B是無法感知的,因此同樣可能會存在協程泄露的現象。

上下文管理

下面我們會引進一個利用Go標準庫context包的處理方式,通過上下文管理子協程的生命週期。

官方概念

在此之前,先來複習下標準庫的概念:

A Context carries a deadline, a cancellation signal, and other values across API boundaries.
Context’s methods may be called by multiple goroutines simultaneously.
上下文帶着截止時間、cancel信號、還有在API之間提供值讀寫。

type Context interface {
	// 返回該上下文的截止時間,如果沒有設置截至時間,第二個值返回false
	Deadline() (deadline time.Time, ok bool)

	// 返回一個管道,上下文結束(cancel)時該方法會執行,經常結合select塊監聽
	Done() <-chan struct{}

	// 當Done()執行時,Err()會返回一個error解釋退出原因
	Err() error

	// 上下文值存儲字典
	Value(key interface{}) interface{}
}

其中較爲常用的是context.Withcancel()函數,它會返回一個包裝了cancel()函數的子上下文,當我們認爲協程需要結束的時候,調用其返回值cancel()函數,子上下文會關閉內部封裝的管道Done()來通知相應協程。

// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    //返回新的cancelCtx子上下文
    c := newCancelCtx(parent)
    //將原上下文
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

我們繼續引入一個協程泄漏的demo,來看下具體使用例子:
使用示例:

//阻塞兩個子協程,預期只有一個協程會正常退出
func LeakSomeRoutine() int {
	ch := make(chan int)
	//起3個協程搶着輸入到ch
	go func() {
		ch <- 1
	}()

	go func() {
		ch <- 2
	}()

	go func() {
		ch <- 3
	}()
	//一有輸入立刻返回
	return <-ch
}

func main() {
	//每一層循環泄漏兩個協程
	for i := 0; i < 4; i++ {
		LeakSomeRoutine()
		fmt.Printf("#Goroutines in roop end: %d.\n", runtime.NumGoroutine())
	}
}

程序輸出:

#Goroutines in roop end: 3.
#Goroutines in roop end: 5.
#Goroutines in roop end: 7.
#Goroutines in roop end: 9.

可以看到,隨着循環次數增加,除去main協程,每一輪都泄漏兩個協程,所以程序退出之前最終有9個協程。

接下來我們引入上下文的概念,來管理子協程:

func FixLeakingByContex() {
	//創建上下文用於管理子協程
	ctx, cancel := context.WithCancel(context.Background())

	//結束前清理未結束協程
	defer cancel()

	ch := make(chan int)
	go CancelByContext(ctx, ch)
	go CancelByContext(ctx, ch)
	go CancelByContext(ctx, ch)

	// 隨機觸發某個子協程退出
	ch <- 1
}

func CancelByContext(ctx context.Context, ch chan (int)) int {
	select {
	case <-ctx.Done():
		//fmt.Println("cancel by ctx.")
		return 0
	case n := <-ch :
		return n
	}
}

func main() {
	//每一層循環泄漏兩個協程
	for i := 0; i < 4; i++ {
		FixLeakingByContex()
		//給它點時間 異步清理協程
		time.Sleep(100)
		fmt.Printf("#Goroutines in roop end: %d.\n", runtime.NumGoroutine())
	}
}

程序分析:
可以看到CancelByContext函數對管道進行輪詢,程序只有兩個分支方可return

  • 上下文通知結束
  • 收到管道傳入的值

我們在FixLeakingByContex()函數結束前defer了cancel()函數,因此會在程序退出前把相應的子協程clean掉,所以可以看到如下輸出,每一輪都只剩下一個main協程。

程序輸出:

#Goroutines in roop end: 1.
#Goroutines in roop end: 1.
#Goroutines in roop end: 1.
#Goroutines in roop end: 1.

截止退出

除了我們手動調用cancel函數退出之外,標準庫還提供了兩個限時退出的操作,WithDeadline(parent Context, d time.Time)以及WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)函數,可以傳入一個時間點或者時間段,表示經過該時間之後自動調用該上下文的Done()函數。

使用示例:
我們摘取官網一個demo:

func main() {
	// 由於傳入時間爲50微妙,因此在select選擇塊中,ctx.Done()分支會先執行
	ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
	defer cancel()

	select {
	case <-time.After(1 * time.Second):
		fmt.Println("overslept")
	case <-ctx.Done():
		fmt.Println(ctx.Err()) // prints "context deadline exceeded"
	}
}
//輸出: context deadline exceeded

請求超時

關於上下文WithTimeout()函數的用法,還可以判斷一個http請求是否超時。

程序示例:

func TestTimeReqWithContext(t *testing.T) {
	//初始化http請求
	request, e := http.NewRequest("GET", "https://www.pixelpigpigpig.xyz", nil)
	if e != nil {
		log.Println("Error: ", e)
		return
	}

	//使用Request生成子上下文, 並且設置截止時間爲10毫秒
	ctx, cancelFunc := context.WithTimeout(request.Context(), 10*time.Millisecond)
	defer cancelFunc()

	//綁定超時上下文到這個請求
	request = request.WithContext(ctx)

	//time.Sleep(20 * time.Millisecond)

	//發起請求
	response, e := http.DefaultClient.Do(request)
	if e != nil {
		log.Println("Error: ", e)
		return
	}

	defer response.Body.Close()
	//如果請求沒問題, 打印body到控制檯
	io.Copy(os.Stdout, response.Body)

}

我們給請求限時10毫秒,執行可以看到程序打印上下文已經過期了。
輸出示例:

=== RUN   TestTimeReqWithContext
2020/05/16 23:17:14 Error:  Get https://www.pixelpigpigpig.xyz: context deadline exceeded

Context值讀寫

如前面所示, context還提供了一個讀寫的函數簽名:

// 上下文值存儲字典
Value(key interface{}) interface{}

關於它的用法,之前在“聊一聊httpRouter”的文章中列舉過,可以這樣子使用,

示例:

// 獲取頂級上下文
ctx := context.Background()
// 在上下文寫入string值, 注意需要返回新的value上下文
valueCtx := context.WithValue(ctx, "hello", "pixel")
value := valueCtx.Value("hello")
if value != nil {
    /*
        已知寫入值是string,所以我們也可以直接進行類型斷言
        比如: p, _ := ctx.Value(ParamsKey).(Params)
        這個下劃線其實是go斷言返回的bool值
    */
	fmt.Printf("Params type: %v, value: %v.\n", reflect.TypeOf(value), value)
}

context.Background()是一個非空的頂級上下文,只要程序還在它就不會取消,也沒有嵌入值,經常被main函數所使用。
關於context的值讀取,在Go規範中有一個約定,不應使用它來封裝壽命長期的參數,一般僅用於傳輸一個請求作用域的值,關於程序的業務參數應該暴露出來,放在函數參數列表中,以提高可讀性。


Context的作用域

上面列舉的Context幾個用法,可以說比較常見,前面曾經拿gocontext的來類比Java Spring中的applicationContext全局上下文,但是嚴格來說,其實這個是帶有爭議的。因爲Spring的上下文是貫穿整個程序的生命週期,往往會附帶一些全局設置項,在go中,比較傾向於用於控制一個子程序塊,和Spring的全局上下文比較,Gocontext是比較短暫的。

Go裏面context.Context的常用情景:

  • 用於貫穿一個子協程的任務片段,如上面用於把控子協程的退出
  • 或者是在網絡框架中表示一個請求,管理其開始至結束,如在Gin框架中的從RequestResponse,並不是全局的。

另外在參考鏈接處有一篇比較有意思的爭論,有個作者吐槽了關於Go上下文的泛濫,關於上下文的辯論在該文章的評論可謂見仁見智。

Use context values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions

總的來說,Go上下文應該着重用於管理子協程或者是有目地嵌入函數鏈的生命週期中,而不是約定俗成在每個方法都傳播上下文。

參考鏈接

Goroutine leak
https://medium.com/golangspec/goroutine-leak-400063aef468
Understanding the context package
https://medium.com/rungo/understanding-the-context-package-b2e407a9cdae
Context Package Semantics In Go
https://www.ardanlabs.com/blog/2019/09/context-package-semantics-in-go.html
Context should go away for Go 2(“關於Go上下文氾濫的吐槽”)
https://faiface.github.io/post/context-should-go-away-go2/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章