九. Go併發編程--context.Context

一. 序言

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