九. Go併發程式設計--context.Context

failymao發表於2021-11-17

一. 序言

1.1 場景一

現在有一個 Server 服務在執行,當請求來的時候我們啟動一個 goroutine 去處理,然後在這個 goroutine 當中有對下游服務的 rpc 呼叫,也會去請求資料庫獲取一些資料,這時候如果下游依賴的服務比較慢,但是又沒掛,只是很慢,可能一次呼叫要 1min 才能返回結果,這個時候我們該如何處理?

如下圖所示, 首先假設我們使用WaitGroup進行控制, 等待所有的goroutine處理完成之後返回,可以看到我們實際的好事遠遠大於了使用者可以容忍的時間

如下圖所示,再考慮一個常見的場景,萬一上面的 rpc goroutine 很早就報錯了,但是 下面的 db goroutine 又執行了很久,我們最後要返回錯誤資訊,很明顯後面 db goroutine 執行的這段時間都是在白白的浪費使用者的時間。

這時候就應該請出context包了, context主要就是用來在多個 goroutine中設定截至日期, 同步訊號, 傳遞請求相關值。

每一次 context 都會從頂層一層一層的傳遞到下面一層的 goroutine 當上面的 context 取消的時候,下面所有的 context 也會隨之取消

上面的例子當中,如果引入 context 後就會是這樣,如下圖所示,context 會類似一個樹狀結構一樣依附在每個 goroutine 上,當上層的 req goroutine 的 context 超時之後就會將取消訊號同步到下面的所有 goroutine 上一起返回,從而達到超時控制的作用

如下圖所示,當 rpc 呼叫失敗之後,會出發 context 取消,然後這個取消訊號就會同步到其他的 goroutine 當中

1.2 還有一種場景

Golang context是Golang應用開發常用的併發控制技術,它與WaitGroup最大的不同點是context對於派生goroutine有更強的控制力,它可以控制多級的goroutine。

context翻譯成中文是 上下文,即它可以控制一組呈樹狀結構的goroutine,每個goroutine擁有相同的上下文。

典型的使用場景如圖所示

上圖中由於goroutine派生出子goroutine,而子goroutine又繼續派生新的goroutine,這種情況下使用WaitGroup就不太容易,因為子goroutine個數不容易確定。

二. context

2.1 使用說明

2.1.1 使用準則

下面幾條準則

  • 對 server 應用而言,傳入的請求應該建立一個 context
  • 通過 WithCancel , WithDeadline , WithTimeout 建立的 Context 會同時返回一個 cancel 方法,這個方法必須要被執行,不然會導致 context 洩漏,這個可以通過執行 go vet 命令進行檢查
  • 應該將 context.Context 作為函式的第一個引數進行傳遞,引數命名一般為 ctx 不應該將 Context 作為欄位放在結構體中。
  • 不要給 context 傳遞 nil,如果你不知道應該傳什麼的時候就傳遞 context.TODO()
  • 不要將函式的可選引數放在 context 當中,context 中一般只放一些全域性通用的 metadata 資料,例如 tracing id 等等
  • context 是併發安全的可以在多個 goroutine 中併發呼叫

2.1.2 函式簽名

context 包暴露的方法不多,看下方說明即可

// 建立一個帶有新的 Done channel 的 context,並且返回一個取消的方法
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
// 建立一個具有截止時間的 context
// 截止時間是 d 和 parent(如果有截止時間的話) 的截止時間中更早的那一個
// 當 parent 執行完畢,或 cancel 被呼叫 或者 截止時間到了的時候,這個 context done 掉
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
// 其實就是呼叫的 WithDeadline
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
type CancelFunc
type Context interface 
	// 一般用於建立 root context,這個 context 永遠也不會被取消,或者是 done
    func Background() Context
	// 底層和 Background 一致,但是含義不同,當不清楚用什麼的時候或者是還沒準備好的時候可以用它
    func TODO() Context
	// 為 context 附加值
	// key 應該具有可比性,一般不應該是 string int 這種預設型別,應該自己建立一個型別
	// 避免出現衝突,一般 key 不應該匯出,如果要匯出的話應該是一個介面或者是指標
    func WithValue(parent Context, key, val interface{}) Context
 

2.2. 原始碼

2.2.1 context.Context介面

type Context interface {
    // 返回當前 context 的結束時間,如果 ok = false 說明當前 context 沒有設定結束時間
	Deadline() (deadline time.Time, ok bool)
    // 返回一個 channel,用於判斷 context 是否結束,多次呼叫同一個 context done 方法會返回相同的 channel
	Done() <-chan struct{}
    // 當 context 結束時才會返回錯誤,有兩種情況
    // context 被主動呼叫 cancel 方法取消:Canceled
    // context 超時取消: DeadlineExceeded
	Err() error
    // 用於返回 context 中儲存的值, 如何查詢,這個後面會講到
	Value(key interface{}) interface{}
}

2.2.2 context.Backgroud

一般用於建立 root context,這個 context 永遠也不會被取消,或超時
TODO(), 底層和 Background 一致,但是含義不同,當不清楚用什麼的時候或者是還沒準備好的時候可以用它

var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

檢視原始碼我們可以發現,background 和 todo 都是例項化了一個 emptyCtx. emptyCtx又是一個 int型別的別名

type emptyCtx int

// emptyCtx分別繫結了四個方法,而這四個方法正是 context介面定義的方法,所以emptyCtx實現了 Context介面
func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
	return
}

func (*emptyCtx) Done() <-chan struct{} {
	return nil
}

func (*emptyCtx) Err() error {
	return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
	return nil
}

emptyCtx 就如同他的名字一樣,全都返回空值

2.2.3 WithCancel

WithCancel(), 方法會建立一個可以取消的 context

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
    // 包裝出新的 cancelContext
	c := newCancelCtx(parent)
    // 構建父子上下文的聯絡,確保當父 Context 取消的時候,子 Context 也會被取消
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

傳入的引數是一個實現了Context介面的型別(root context),且不能為 nil, 所以我們經常使用方式如下:

// context.Background 返回的是一個 root context
context.WithCancel(context.Background())

不止 WithCancel 方法,其他的 WithXXX 方法也不允許傳入一個 nil 值的父 context
newCancelCtx 只是一個簡單的包裝就不展開了, propagateCancel 比較有意思,我們一起來看看

func propagateCancel(parent Context, child canceler) {
	// 首先判斷 parent 能不能被取消
    done := parent.Done()
	if done == nil {
		return // parent is never canceled
	}

    // 如果可以,看一下 parent 是不是已經被取消了,已經被取消的情況下直接取消 子 context
	select {
	case <-done:
		// parent is already canceled
		child.cancel(false, parent.Err())
		return
	default:
	}

    // 這裡是向上查詢可以被取消的 parent context
	if p, ok := parentCancelCtx(parent); ok {
        // 如果找到了並且沒有被取消的話就把這個子 context 掛載到這個 parent context 上
        // 這樣只要 parent context 取消了子 context 也會跟著被取消
		p.mu.Lock()
		if p.err != nil {
			// parent has already been canceled
			child.cancel(false, p.err)
		} else {
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else {
        // 如果沒有找到的話就會啟動一個 goroutine 去監聽 parent context 的取消 channel
        // 收到取消訊號之後再去呼叫 子 context 的 cancel 方法
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}

接下來我們就看看 cancelCtx 長啥樣

type cancelCtx struct {
	Context // 這裡儲存的是父 Context

	mu       sync.Mutex            // 互斥鎖
	done     chan struct{}         // 關閉訊號
	children map[canceler]struct{} // 儲存所有的子 context,當取消的時候會被設定為 nil
	err      error
}

Done()

在 Done 方法這裡採用了 懶漢式載入的方式,第一次呼叫的時候才會去建立這個 channel

func (c *cancelCtx) Done() <-chan struct{} {
	c.mu.Lock()
	if c.done == nil {
		c.done = make(chan struct{})
	}
	d := c.done
	c.mu.Unlock()
	return d
}

Value()

value方法很有意思,這裡相當於是內部 cancelCtxKey 這個變數的地址作為了一個特殊的 key,當查詢這個 key 的時候就會返回當前 context 如果不是這個 key 就會向上遞迴的去呼叫 parent context 的 Value 方法查詢有沒有對應的值

func (c *cancelCtx) Value(key interface{}) interface{} {
	if key == &cancelCtxKey {
		return c
	}
	return c.Context.Value(key)
}

在前面講到構建父子上下文之間的關係的時候,有一個去查詢可以被取消的父 context 的方法 parentCancelCtx 就用到了這個特殊 value

func parentCancelCtx(parent Context) (*cancelCtx, bool) {
    // 這裡先判斷傳入的 parent 是否是一個有效的 chan,如果不是是就直接返回了
	done := parent.Done()
	if done == closedchan || done == nil {
		return nil, false
	}

    // 這裡利用了 context.Value 不斷向上查詢值的特點,只要出現第一個可以取消的 context 的時候就會返回
    // 如果沒有的話,這時候 ok 就會等於 false
	p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
	if !ok {
		return nil, false
	}
    // 這裡去判斷返回的 parent 的 channel 和傳入的 parent 是不是同一個,是的話就返回這個 parent
	p.mu.Lock()
	ok = p.done == done
	p.mu.Unlock()
	if !ok {
		return nil, false
	}
	return p, true
}

接下來我們來看最重要的這個 cancel 方法,cancel 接收兩個引數,removeFromParent 用於確認是不是把自己從 parent context 中移除,err 是 ctx.Err() 最後返回的錯誤資訊

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
	if err == nil {
		panic("context: internal error: missing cancel error")
	}
	
	// 互斥鎖用來保證併發安全
	c.mu.Lock()
	if c.err != nil {
		c.mu.Unlock()
		return // already canceled
	}
	c.err = err
   
    // 由於 cancel context 的 done 是懶載入的,所以有可能存在還沒有初始化的情況
	if c.done == nil {
		c.done = closedchan
	} else {
		close(c.done)
	}
	
    // 迴圈的將所有的子 context 取消掉
	for child := range c.children {
		// NOTE: acquiring the child's lock while holding parent's lock.
		child.cancel(false, err)
	}
    // 將所有的子 context 和當前 context 關係解除
	c.children = nil
	c.mu.Unlock()

    // 如果需要將當前 context 從 parent context 移除,就移除掉
	if removeFromParent {
		removeChild(c.Context, c)
	}
}

2.2.4 WithTimeout

WithTimeout 其實就是呼叫了 WithDeadline 然後再傳入的引數上用當前時間加上了 timeout 的時間

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
   return WithDeadline(parent, time.Now().Add(timeout))
}

再來看一下實現超時的 timerCtx,WithDeadline 我們放到後面一點點

type timerCtx struct {
	cancelCtx // 這裡複用了 cancelCtx
	timer *time.Timer // Under cancelCtx.mu.

	deadline time.Time // 這裡儲存了快到期的時間
}

Deadline() 就是返回了結構體中儲存的過期時間

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
	return c.deadline, true
}

cancel 其實就是複用了 cancelCtx 中的取消方法,唯一區別的地方就是在後面加上了對 timer 的判斷,如果 timer 沒有結束主動結束 timer

func (c *timerCtx) cancel(removeFromParent bool, err error) {
	c.cancelCtx.cancel(false, err)
	if removeFromParent {
		// Remove this timerCtx from its parent cancelCtx's children.
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil {
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}

timerCtx 並沒有重新實現 Done() 和 Value 方法,直接複用了 cancelCtx 的相關方法

2.2.5 WithDeadline

原始碼如下

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}

   	// 會先判斷 parent context 的過期時間,如果過期時間比當前傳入的時間要早的話,就沒有必要再設定過期時間了
    // 只需要返回 WithCancel 就可以了,因為在 parent 過期的時候,子 context 也會被取消掉
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		// The current deadline is already sooner than the new one.
		return WithCancel(parent)
	}

    // 構造相關結構體
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}

    // 和 WithCancel 中的邏輯相同,構建上下文關係
	propagateCancel(parent, c)

    // 判斷傳入的時間是不是已經過期,如果已經過期了就 cancel 掉然後再返回
	dur := time.Until(d)
	if dur <= 0 {
		c.cancel(true, DeadlineExceeded) // deadline has already passed
		return c, func() { c.cancel(false, Canceled) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()

    // 這裡是超時取消的邏輯,啟動 timer 時間到了之後就會呼叫取消方法
	if c.err == nil {
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}

可以發現超時控制其實就是在複用 cancelCtx 的基礎上加上了一個 timer 來做定時取消

2.2.6 WithValue

主要就是校驗了一下 Key 是不是可比較的,然後構造出一個 valueCtx 的結構

func WithValue(parent Context, key, val interface{}) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

valueCtx 主要就是嵌入了 parent context 然後附加了一個 key val

type valueCtx struct {
	Context
	key, val interface{}
}

Value 的查詢和之前 cancelCtx 類似,都是先判斷當前有沒有,沒有就向上遞迴,只是在 cancelCtx 當中 key 是一個固定的 key 而已

func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}

Value 就沒有實現 Context 介面的其他方法了,其他的方法全都是複用的 parent context 的方法

三. 使用案例

3.1 使用cancel context

package main

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

// 模擬請求
func HandelRequest(ctx context.Context) {
	// 模擬講資料寫入redis
	go WriteRedis(ctx)

	// 模擬講資料寫入資料庫
	go WriteDatabase(ctx)

	for {
		select {
		case <-ctx.Done():
			fmt.Println("HandelRequest Done.")
			return
		default:
			fmt.Println("HandelRequest running")
			time.Sleep(2 * time.Second)
		}
	}
}

func WriteRedis(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("WriteRedis Done.")
			return
		default:
			fmt.Println("WriteRedis running")
			time.Sleep(2 * time.Second)
		}
	}
}

func WriteDatabase(ctx context.Context) {
    // for 迴圈模擬間歇性嘗試
	for {
		select {
		case <-ctx.Done():
			fmt.Println("WriteDatabase Done.")
			return
		default:
			fmt.Println("WriteDatabase running")
			time.Sleep(2 * time.Second)
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	go HandelRequest(ctx)

	// 模擬耗時
	time.Sleep(5 * time.Second)
	fmt.Println("It's time to stop all sub goroutines!")
	cancel()

	// Just for test whether sub goroutines exit or not
	time.Sleep(5 * time.Second)
}

上面程式碼中協程HandelRequest()用於處理某個請求,其又會建立兩個協程:WriteRedis()、WriteDatabase(),main協程建立context,並把context在各子協程間傳遞,main協程在適當的時機可以cancel掉所有子協程。

程式輸出如下所示:

HandelRequest running
WriteRedis running
WriteDatabase running
HandelRequest running
WriteRedis running
WriteDatabase running
WriteRedis running
WriteDatabase running
HandelRequest running
It's time to stop all sub goroutines!
WriteDatabase Done.
HandelRequest Done.
WriteRedis Done.

3.2 WithTimeout 使用

用WithTimeout()獲得一個context並在其子協程中傳遞:

package main

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

func HandelRequest2(ctx context.Context) {
	go WriteRedis2(ctx)
	go WriteDatabase2(ctx)
	for {
		select {
		case <-ctx.Done():
			fmt.Println("HandelRequest Done.")
			return
		default:
			fmt.Println("HandelRequest running")
			time.Sleep(2 * time.Second)
		}
	}
}

func WriteRedis2(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("WriteRedis Done.")
			return
		default:
			fmt.Println("WriteRedis running")
			time.Sleep(2 * time.Second)
		}
	}
}

func WriteDatabase2(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("WriteDatabase Done.")
			return
		default:
			fmt.Println("WriteDatabase running")
			time.Sleep(2 * time.Second)
		}
	}
}

func main() {
	// 定義超時時間,當超過定義的時間後會自動執行 context cancel, 從而終止字寫成
	ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
	go HandelRequest2(ctx)

	// 模擬阻塞等待防止主執行緒退出
	time.Sleep(10 * time.Second)
}

主協程中建立一個5s超時的context,並將其傳遞給子協程,10s自動關閉context。程式輸出如下:

HandelRequest running
WriteDatabase running
WriteRedis running                        # 迴圈第一次 耗時1s         
WriteRedis running                        # 迴圈第二次 耗時3s
WriteDatabase running
HandelRequest running
HandelRequest running
WriteRedis running                        # 迴圈第三次 耗時5s
WriteDatabase running
WriteRedis Done.
HandelRequest Done.
WriteDatabase Done.

3.3 Value值傳遞

下面示例程式展示valueCtx的用法:

package main

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

func HandelRequest3(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("HandelRequest Done.")
			return
		default:
			fmt.Println("HandelRequest running, parameter: ", ctx.Value("parameter"))
			time.Sleep(2 * time.Second)
		}
	}
}

func main() {
	// 取消時,將父 context value值傳遞給子context
	ctx := context.WithValue(context.Background(), "parameter", "1")
	go HandelRequest3(ctx)

	time.Sleep(10 * time.Second)
}

輸出:

HandelRequest running, parameter:  1
HandelRequest running, parameter:  1
HandelRequest running, parameter:  1
HandelRequest running, parameter:  1
HandelRequest running, parameter:  1

上例main()中通過WithValue()方法獲得一個context,需要指定一個父context、key和value。然後通將該context傳遞給子協程HandelRequest,子協程可以讀取到context的key-value。

注意:本例中子協程無法自動結束,因為context是不支援cancle的,也就是說<-ctx.Done()永遠無法返回。

如果需要返回,需要在建立context時指定一個可以cancelcontext作為父節點,使用父節點的cancel()在適當的時機結束整個context。

3.4 錯誤取消

假設我們在 main 中併發呼叫了 f1 f2 兩個函式,但是 f1 很快就返回了,但是 f2 還在阻塞

package main

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

func f1(ctx context.Context) error {
	select {
	case <-ctx.Done():
		return fmt.Errorf("f1: %w", ctx.Err())
	case <-time.After(time.Millisecond): // 模擬短時間報錯
		return fmt.Errorf("f1 err in 1ms")
	}
}

func f2(ctx context.Context) error {
	select {
	case <-ctx.Done():
		return fmt.Errorf("f2: %w", ctx.Err())
	case <-time.After(time.Hour): // 模擬一個耗時操作
		return nil
	}
}

func main() {
	// 超時 context
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	var wg sync.WaitGroup
	wg.Add(2)
	go func() {
		defer wg.Done()
		// f1 由於時間很短,會返回error,從而呼叫 cancel
		if err := f1(ctx); err != nil {
			fmt.Println(err)
			// ctx 呼叫cancel後,會傳遞到f1,f2
			cancel()
		}
	}()

	go func() {
		defer wg.Done()
		if err := f2(ctx); err != nil {
			fmt.Println(err)
			cancel()
		}
	}()

	wg.Wait()
	fmt.Println("exit...")
}

執行結果,可以看到 f1 返回之後 f2 立即就返回了,並且報錯 context 被取消

root@failymao:/mnt/d/gopath/src/Go_base/daily_test# go run context/err_cancel.go
f1 err in 1ms
f2: context canceled
exit...

這個例子其實就是 errgroup 的邏輯,是的它就是類似 errgroup 的簡單邏輯

3.5 傳遞共享資料

一般會用來傳遞 tracing id, request id 這種資料,不要用來傳遞可選引數,這裡借用一下饒大的一個例子,在實際的生產案例中我們程式碼也是這樣大同小異

const requestIDKey int = 0

func WithRequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(
		func(rw http.ResponseWriter, req *http.Request) {
			// 從 header 中提取 request-id
			reqID := req.Header.Get("X-Request-ID")
			// 建立 valueCtx。使用自定義的型別,不容易衝突
			ctx := context.WithValue(
				req.Context(), requestIDKey, reqID)

			// 建立新的請求
			req = req.WithContext(ctx)

			// 呼叫 HTTP 處理函式
			next.ServeHTTP(rw, req)
		}
	)
}

// 獲取 request-id
func GetRequestID(ctx context.Context) string {
	ctx.Value(requestIDKey).(string)
}

func Handle(rw http.ResponseWriter, req *http.Request) {
	// 拿到 reqId,後面可以記錄日誌等等
	reqID := GetRequestID(req.Context())
	...
}

func main() {
	handler := WithRequestID(http.HandlerFunc(Handle))
	http.ListenAndServe("/", handler)
}

3.6 防止 goroutine 洩漏

看一下官方文件的這個例子, 這裡面 gen 這個函式中如果不使用 context done 來控制的話就會導致 goroutine 洩漏,因為這裡面的 for 是一個死迴圈,沒有 ctx 就沒有相關的退出機制

func main() {
	// gen generates integers in a separate goroutine and
	// sends them to the returned channel.
	// The callers of gen need to cancel the context once
	// they are done consuming generated integers not to leak
	// the internal goroutine started by gen.
	gen := func(ctx context.Context) <-chan int {
		dst := make(chan int)
		n := 1
		go func() {
			for {
				select {
				case <-ctx.Done():
					return // returning not to leak the goroutine
				case dst <- n:
					n++
				}
			}
		}()
		return dst
	}

	ctx, cancel := context.WithCancel(context.Background())
	defer cancel() // cancel when we are finished consuming integers

	for n := range gen(ctx) {
		fmt.Println(n)
		if n == 5 {
			break
		}
	}
}

四. 總結

  • 對 server 應用而言,傳入的請求應該建立一個 context,接受
    通過 WithCancel , WithDeadline , WithTimeout 建立的 Context 會同時返回一個 cancel 方法,這個方法必須要被執行,不然會導致 context 洩漏,這個可以通過執行 go vet 命令進行檢查
  • 應該將 context.Context 作為函式的第一個引數進行傳遞,引數命名一般為 ctx 不應該將 Context 作為欄位放在結構體中。
  • 不要給 context 傳遞 nil,如果你不知道應該傳什麼的時候就傳遞 context.TODO()
  • 不要將函式的可選引數放在 context 當中,context 中一般只放一些全域性通用的 metadata 資料,例如 tracing id 等等
  • context 是併發安全的可以在多個 goroutine 中併發呼叫

4.1 使用場景

  • 超時控制
  • 錯誤取消
  • 跨 goroutine 資料同步
  • 防止 goroutine 洩漏

4.2 缺點

  1. 最顯著的一個就是 context 引入需要修改函式簽名,並且會病毒的式的擴散到每個函式上面,不過這個見仁見智,我看著其實還好
  2. 某些情況下雖然是可以做到超時返回提高使用者體驗,但是實際上是不會退出相關 goroutine 的,這時候可能會導致 goroutine 的洩漏,針對這個我們來看一個例子

我們使用標準庫的 timeout handler 來實現超時控制,底層是通過 context 來實現的。我們設定了超時時間為 1ms 並且在 handler 中模擬阻塞 1000s 不斷的請求,然後看 pprof 的 goroutine 資料

package main

import (
	"net/http"
	_ "net/http/pprof"
	"time"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
		// 這裡阻塞住,goroutine 不會釋放的
		time.Sleep(1000 * time.Second)
		rw.Write([]byte("hello"))
	})
	handler := http.TimeoutHandler(mux, time.Millisecond, "xxx")
	go func() {
		if err := http.ListenAndServe("0.0.0.0:8066", nil); err != nil {
			panic(err)
		}
	}()
	http.ListenAndServe(":8080", handler)
}

檢視資料我們可以發現請求返回後, goroutine 其實並未回收,但是如果不阻塞的話是會立即回收的

goroutine profile: total 29
24 @ 0x103b125 0x106cc9f 0x1374110 0x12b9584 0x12bb4ad 0x12c7fbf 0x106fd01

看它的原始碼,超時控制主要在 ServeHTTP 中實現,我刪掉了部分不關鍵的資料, 我們可以看到函式內部啟動了一個 goroutine 去處理請求邏輯,然後再外面等待,但是這裡的問題是,當 context 超時之後 ServeHTTP 這個函式就直接返回了,在這裡面啟動的這個 goroutine 就沒人管了

func (h *timeoutHandler) ServeHTTP(w ResponseWriter, r *Request) {
	ctx := h.testContext
	if ctx == nil {
		var cancelCtx context.CancelFunc
		ctx, cancelCtx = context.WithTimeout(r.Context(), h.dt)
		defer cancelCtx()
	}
	r = r.WithContext(ctx)
	done := make(chan struct{})
	tw := &timeoutWriter{
		w:   w,
		h:   make(Header),
		req: r,
	}
	panicChan := make(chan interface{}, 1)
	go func() {
		defer func() {
			if p := recover(); p != nil {
				panicChan <- p
			}
		}()
		h.handler.ServeHTTP(tw, r)
		close(done)
	}()
	select {
	case p := <-panicChan:
		panic(p)
	case <-done:
		// ...
	case <-ctx.Done():
		// ...
	}
}

4.3 總結

context 是一個優缺點都十分明顯的包,這個包目前基本上已經成為了在 go 中做超時控制錯誤取消的標準做法,但是為了新增超時取消我們需要去修改所有的函式簽名,對程式碼的侵入性比較大,如果之前一直都沒有使用後續再新增的話還是會有一些改造成本

這篇真的很長

五.參考文獻

  1. context · pkg.go.dev
  2. Go 語言實戰筆記(二十)| Go Context
  3. https://lailin.xyz/post/go-training-week3-context.htm
  4. https://www.topgoer.cn/docs/gozhuanjia/chapter055.3-context

相關文章