go context詳解


前言

平時在 Go 工程的開發中,幾乎所有服務端的默認實現(例如:HTTP Server),都在處理請求時開啟了新的 goroutine 進行處理。

但從一開始就存在一個問題,那就是當一個請求被取消或超時時,所有在該請求上工作的 goroutine 應該迅速退出,以便系統可以回收他們正在使用的資源。

因此 Go 官方在2014年,Go 1.7 版本中正式引入了 context 標准庫。其主要作用是在 goroutine 中進行上下文的傳遞,在傳遞信息中又包含了 goroutine 的運行控制、上下文信息傳遞等功能。

什么是 context

Context 是Go 語言獨有功能之一,用於上下文控制,可以在 goroutine 中進行傳遞。

contextselect-case 聯合,還可以實現上下文的截止時間、信號控制、信息傳遞等跨 goroutine 的操作,是 Go 語言協程的重要組成部分。

context 基本特性

在 Go context 用法中,我們常常將其與 select 關鍵字結合使用,用於監聽其是否結束、取消等。

演示代碼:

func main() {
	parentCtx := context.Background()
	ctx, cancel := context.WithTimeout(parentCtx, 1*time.Millisecond)
	defer cancel()

	select {
	case <-time.After(1 * time.Second):
		fmt.Println("overslept")
	case <-ctx.Done():
		fmt.Println(ctx.Err())
	}
}

輸出結果:

context deadline exceeded

我們通過調用標准庫 context.WithTimeout 方法針對 parentCtx 變量設置了超時時間,並在隨后調用 select-case 進行 context.Done 方法的監聽,最后由於達到了截止時間。因此邏輯上 select 走到了 context.Errcase 分支,最終輸出 context deadline exceeded

除了上述所描述的方法外,標准庫 context 還支持下述方法:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
type Context
    func Background() Context
    func TODO() Context
    func WithValue(parent Context, key, val interface{}) Context
  • WithCancel:基於父級 context,創建一個可以取消的新 context。
  • WithDeadline:基於父級 context,創建一個具有截止時間(Deadline)的新 context。
  • WithTimeout:基於父級 context,創建一個具有超時時間(Timeout)的新 context。
  • Background:創建一個空的 context,一般常用於作為根的父級 context。
  • TODO:創建一個空的 context,一般用於未確定時的聲明使用。
  • WithValue:基於某個 context 創建並存儲對應的上下文信息。

如果是更進一步結合 goroutine 的話,常見的例子是:

func(ctx context.Context) <-chan int {
  dst := make(chan int)
  n := 1
  go func() {
   for {
    select {
    case <-ctx.Done():
     return
    case dst <- n:
     n++
    }
   }
  }()
  return dst
 }

我們平時工程中會起很多的 goroutine,這時候會在 goroutine 內結合 for+select,針對 context 的事件進行處理,達到跨 goroutine 控制的目的。

context 正確使用方式

對第三方調用傳入 context

在 Go 語言中,Context 已經是默認支持的規范了。因此我們對第三方有調用訴求的時候,可以傳入 context:

func main() {
 req, err := http.NewRequest("GET", "https://xxx.com/", nil)
 if err != nil {
  fmt.Printf("http.NewRequest err: %+v", err)
  return
 }

 ctx, cancel := context.WithTimeout(req.Context(), 50*time.Millisecond)
 defer cancel()

 req = req.WithContext(ctx)
 resp, err := http.DefaultClient.Do(req)
 if err != nil {
  fmt.Printf("http.DefaultClient.Do err: %+v", err)
  return
 }
 defer resp.Body.Close()
}

一般第三方開源庫都已經實現了根據 context 的超時控制,所以當程序超時時,將會中斷請求。

若你發現第三方開源庫沒有支持 context,建議換一個,免得出現級聯故障。

不要將上下文存儲在結構類型中

大家會發現,在 Go 語言中,所有的第三方開源庫,業務代碼。幾乎清一色的都會將 context 放在方法的一個入參參數,作為首位形參。
例如:

標准要求:每個方法的第一個參數都將 context 作為第一個參數,並使用 ctx 變量名慣用語。
當然,也有極少數把 context 放在結構體中的。基本常見於:

  • 底層基礎庫。
  • DDD 結構。

每個請求都是獨立的,context 自然每個都不一樣,想清楚自己的應用使用場景很重要,否則遵循 Go 基本規范就好。

函數調用鏈必須傳播上下文

我們會把 context 作為方法首位,本質目的是為了傳播 context,自行完整調用鏈路上的各類控制:

func List(ctx context.Context, db *sqlx.DB) ([]User, error) {
 ctx, span := trace.StartSpan(ctx, "internal.user.List")
 defer span.End()

 users := []User{}
 const q = `SELECT * FROM users`

 if err := db.SelectContext(ctx, &users, q); err != nil {
  return nil, errors.Wrap(err, "selecting users")
 }

 return users, nil
}

像在上述例子中,我們會把所傳入方法的 context 一層層的傳進去下一級方法。這里就是將外部的 context 傳入 List 方法,再傳入 SQL 執行的方法,解決了 SQL 執行語句的時間問題。

context 的繼承和派生

在 Go 標准庫 context 中具有以下派生 context 的標准方法:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

代碼例子如下:

func handle(w http.ResponseWriter, req *http.Request) {
  // parent context
 timeout, _ := time.ParseDuration(req.FormValue("timeout"))
 ctx, cancel := context.WithTimeout(context.Background(), timeout)

  // chidren context
 newCtx, cancel := context.WithCancel(ctx)
 defer cancel()
 // do something...
}

一般會有父級 context 和子級 context 的區別,我們要保證在程序的行為中上下文對於多個 goroutine 同時使用是安全的。並且存在父子級別關系,父級 context 關閉或超時,可以繼而影響到子級 context 的程序。

不傳遞 nil context

很多時候我們在創建 context 時,還不知道其具體的作用和下一步用途是什么。

這種時候大家可能會直接使用 context.Background 方法:

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

func Background() Context {
   return background
}

func TODO() Context {
   return todo
}

但在實際的 context 建議中,我們會建議使用 context.TODO 方法來創建頂級的 context,直到弄清楚實際 Context 的下一步用途,再進行變更。

context 僅傳遞必要的值

我們在使用 context 作為上下文時,經常有信息傳遞的訴求。像是在 gRPC 中就會有 metadata 的概念,而在 gin 中就會自己封裝 context 作為參數管理。
Go 標准庫 context 也有提供相關的方法:

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

代碼例子如下:

func main() {
 type favContextKey string
 f := func(ctx context.Context, k favContextKey) {
  if v := ctx.Value(k); v != nil {
   fmt.Println("found value:", v)
   return
  }
  fmt.Println("key not found:", k)
 }

 k := favContextKey("小米")
 ctx := context.WithValue(context.Background(), k, "小米")

 f(ctx, k)
 f(ctx, favContextKey("小紅"))
}

輸出結果:

found value: 小米
key not found: 小紅

在規范中,建議 context 在傳遞時,僅攜帶必要的參數給予其他的方法,或是 goroutine。甚至在 gRPC 中做嚴格的出、入上下文參數的控制。

在業務場景上,context 傳值適用於傳必要的業務核心屬性,例如:租戶號、小程序ID 等。不要將可選參數放到 context 中,否則可能會一團糟。

總結

  • 對第三方調用要傳入 context,用於控制遠程調用。
  • 不要將上下文存儲在結構類型中,盡可能的作為函數第一位形參傳入。
  • 函數調用鏈必須傳播上下文,實現完整鏈路上的控制。
  • context 的繼承和派生,保證父、子級 context 的聯動。
  • 不傳遞 nil context,不確定的 context 應當使用 TODO。
  • context 僅傳遞必要的值,不要讓可選參數揉在一起。

context 本質

我們在基本特性中介紹了不少 context 的方法,其基本大同小異。看上去似乎不難,接下來我們看看其底層的基本原理和設計。

context 相關函數的標准返回如下:

func WithXXXX(parent Context, xxx xxx) (Context, CancelFunc)

其返回值分別是 Context 和 CancelFunc,接下來我們將進行分析這兩者的作用。

接口

Context 接口:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  • Deadline:獲取當前 context 的截止時間。
  • Done:獲取一個只讀的 channel,類型為結構體。可用於識別當前 channel 是否已經被關閉,其原因可能是到期,也可能是被取消了。
  • Err:獲取當前 context 被關閉的原因。
  • Value:獲取當前 context 對應所存儲的上下文信息。

Canceler 接口:

type canceler interface {
	cancel(removeFromParent bool, err error)
	Done() <-chan struct{}
}
  • cancel:調用當前 context 的取消方法。
  • Done:與前面一致,可用於識別當前 channel 是否已經被關閉。

基礎結構

在標准庫 context 的設計上,一共提供了四類 context 類型來實現上述接口。分別是 emptyCtxcancelCtxtimerCtx 以及 valueCtx
image

emptyCtx

在日常使用中,常常使用到的 context.Background 方法,又或是 context.TODO 方法。

源碼如下:

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

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

其本質上都是基於 emptyCtx 類型的基本封裝。而 emptyCtx 類型本質上是實現了 Context 接口:

type emptyCtx int

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 類型的 context 的實現非常簡單,因為他是空 context 的定義,因此沒有 deadline,更沒有 timeout,可以認為就是一個基礎空白 context 模板。

cancelCtx

在調用 context.WithCancel 方法時,我們會涉及到 cancelCtx 類型,其主要特性是取消事件。源碼如下:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

func newCancelCtx(parent Context) cancelCtx {
	return cancelCtx{Context: parent}
}

其中的 newCancelCtx 方法將會生成出一個可以取消的新 context,如果該 context 執行取消,與其相關聯的子 context 以及對應的 goroutine 也會收到取消信息。

首先 main goroutine 創建並傳遞了一個新的 context 給 goroutine b,此時 goroutine b 的 context 是 main goroutine context 的子集:
image
傳遞過程中,goroutine b 再將其 context 一個個傳遞給了 goroutine c、d、e。最后在運行時 goroutine b 調用了 cancel 方法。使得該 context 以及其對應的子集均接受到取消信號,對應的 goroutine 也進行了響應。

接下來我們針對 cancelCtx 類型來進一步看看:

type cancelCtx struct {
	Context

	mu       sync.Mutex            // protects following fields
	done     chan struct{}         // created lazily, closed by first cancel call
	children map[canceler]struct{} // set to nil by the first cancel call
	err      error                 // set to non-nil by the first cancel call
}

該結構體所包含的屬性也比較簡單,主要是 children 字段,其包含了該 context 對應的所有子集 context,便於在后續發生取消事件的時候進行逐一通知和關聯。

而其他的屬性主要用於並發控制(互斥鎖)、取消信息和錯誤的寫入:

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

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
}

func (c *cancelCtx) Err() error {
	c.mu.Lock()
	err := c.err
	c.mu.Unlock()
	return err
}

在上述代碼中可以留意到,done 屬性(只讀 channel)是在真正調用到 Done 方法時才會去創建。需要配合 select-case 來使用。

timerCtx

在調用 context.WithTimeout 方法時,我們會涉及到 timerCtx 類型,其主要特性是 Timeout 和 Deadline 事件,源碼如下:

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

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	...
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
}

你可以發現 timerCtx 類型是基於 cancelCtx 類型的。我們再進一步看看 timerCtx 結構體:

type timerCtx struct {
	cancelCtx
	timer *time.Timer // Under cancelCtx.mu.

	deadline time.Time
}

其實 timerCtx 類型也就是 cancelCtx 類型,加上 time.Timer 和對應的 Deadline,也就是包含了時間屬性的控制。

我們進一步看看其配套的 cancel 方法,思考一下其是如何進行取消動作的:

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

func (c *timerCtx) cancel(removeFromParent bool, err error) {
	c.cancelCtx.cancel(false, err)
	if removeFromParent {
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil {
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}

先會調用 cancelCtx 類型的取消事件。若存在父級節點,則移除當前 context 子節點,最后停止定時器並進行定時器重置。而 Deadline 或 Timeout 的行為則由 timerCtxWithDeadline 方法實現:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		// The current deadline is already sooner than the new one.
		return WithCancel(parent)
	}
	...
}

該方法會先進行前置判斷,若父級節點的 Deadline 時間早於當前所指定的 Deadline 時間,將會直接生成一個 cancelCtx 的 context。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	...
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
	propagateCancel(parent, c)
	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()
	if c.err == nil {
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}

接下來將會正式生成成為一個 timeCtx 類型,並將其加入到父級 context 是 children 屬性中。最后進行當前時間與 Deadline 時間的計算,並通過調用 time.AfterFunc 在到期后自動調用 cancel 方法發起取消事件,自然也就會觸發父子級的事件傳播。

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

你會發現 valueCtx 結構體也非常的簡單,核心就是鍵值對:

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

其在配套方法上也不會太復雜,基本就是要求可比較,接着就是存儲匹配:

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

這時候你可能又有疑問了,那多個父子級 context 是如何實現跨 context 的上下文信息獲取的?

這秘密其實在上面的 valueCtxValue 方法中有所表現:

本質上 valueCtx 類型是一個單向鏈表,會在調用 Value 方法時先查詢自己的節點是否有該值。若無,則會通過自身存儲的上層父級節點的信息一層層向上尋找對應的值,直到找到為止。

而在實際的工程應用中,你會發現各大框架,例如:gin、grpc 等。他都是有自己再實現一套上下文信息的傳輸的二次封裝,本意也是為了更好的管理和觀察上下文信息。

context 取消事件

在我們針對 context 的各類延伸類型和源碼進行了分析后。我們進一步提出一個疑問點,context 是如何實現跨 goroutine 的取消事件並傳播開來的,是如何實現的?

這個問題的答案就在於 WithCancelWithDeadline 都會涉及到 propagateCancel 方法,其作用是構建父子級的上下文的關聯關系,若出現取消事件時,就會進行處理:

func propagateCancel(parent Context, child canceler) {
	done := parent.Done()
	if done == nil {
		return
	}

	select {
	case <-done:
		child.cancel(false, parent.Err())
		return
	default:
	}
	...
}
  • 當父級上下文(parent)的 Done 結果為 nil 時,將會直接返回,因為其不會具備取消事件的基本條件,可能該 context 是 BackgroundTODO 等方法產生的空白 context。
  • 當父級上下文(parent)的 Done 結果不為 nil 時,則發現父級上下文已經被取消,作為其子級,該 context 將會觸發取消事件並返回父級上下文的取消原因。
func propagateCancel(parent Context, child canceler) {
	...
	if p, ok := parentCancelCtx(parent); ok {
		p.mu.Lock()
		if p.err != nil {
			child.cancel(false, p.err)
		} else {
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else {
		atomic.AddInt32(&goroutines, +1)
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}

經過前面一個代碼片段的判斷,已得知父級 context 未觸發取消事件,當前父級和子級 context 均正常(未取消)。

將會執行以下流程:

  • 調用 parentCancelCtx 方法找到具備取消功能的父級 context。並將當前 context,也就是 child 加入到 父級 context 的 children 列表中,等待后續父級 context 的取消事件通知和響應。
  • 調用 parentCancelCtx 方法沒有找到,將會啟動一個新的 goroutine 去監聽父子 context 的取消事件通知。

通過對 context 的取消事件和整體源碼分析,可得知 cancelCtx 類型的上下文包含了其下屬的所有子節點信息:
image

也就是其在 children 屬性的 map[canceler]struct{} 存儲結構上就已經支持了子級關系的查找,也就自然可以進行取消事件傳播了。

而具體的取消事件的實際行為,則是在前面提到的 propagateCancel 方法中,會在執行例如 cacenl 方法時,會對父子級上下文分別進行狀態判斷,若滿足則進行取消事件,並傳播給子級同步取消。

總結

作為 Go 語言的核心功能之一,其實標准庫 context 非常的短小精悍,使用的都是基本的數據結構和理念。既滿足了跨 goroutine 的調控控制,像是並發、超時控制等。

同時也滿足了上下文的信息傳遞。在工程應用中,例如像是鏈路ID、公共參數、鑒權校驗等,都會使用到 context 作為媒介。

目前官方對於 context 的建議是作為方法的首參數傳入,雖有些麻煩,但也有人選擇將其作為結構體中的一個屬性傳入。但這也會帶來一些心智負擔,需要識別是否重新 new 一個。


免責聲明!

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



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