深入淺出Context原理


1.1 背景

在 Go 服務中,每個傳入的請求都在其自己的goroutine 中處理。請求處理程序通常啟動額外的 goroutine 來訪問其他后端,如數據庫和 RPC服務。處理請求的 goroutine 通常需要訪問特定於請求(request-specific context)的值,例如最終用戶的身份、授權令牌和請求的截止日期(deadline)。當一個請求被取消或超時時,處理該請求的所有 goroutine 都應該快速退出(fail fast),這樣系統就可以回收它們正在使用的任何資源。

Go 1.7 引入一個 context 包,它使得跨 API 邊界的請求范圍元數據、取消信號和截止日期很容易傳遞給處理請求所涉及的所有 goroutine(顯示傳遞)。

其他語言: Thread Local Storage(TLS),XXXContext。

1.2 什么是context?

可以字面意思可以理解為上下文,比較熟悉的有進程/線程上線文,關於Golang中的上下文,一句話概括就是:goroutine的相關環境快照,其中包含函數調用以及涉及的相關的變量值。通過Context可以區分不同的goroutine請求,因為在Golang Severs中,每個請求都是在單個goroutine中完成的。

由於在Golang severs中,每個request都是在單個goroutine中完成,並且在單個goroutine(不妨稱之為A)中也會有請求其他服務(啟動另一個goroutine(稱之為B)去完成)的場景,這就會涉及多個Goroutine之間的調用。如果某一時刻請求其他服務被取消或者超時,則作為深陷其中的當前goroutine B需要立即退出,然后系統才可回收B所占用的資源。
即一個request中通常包含多個goroutine,這些goroutine之間通常會有交互。

img

那么,如何有效管理這些goroutine成為一個問題(主要是退出通知和元數據傳遞問題),Google的解決方法是Context機制,相互調用的goroutine之間通過傳遞context變量保持關聯,這樣在不用暴露各goroutine內部實現細節的前提下,有效地控制各goroutine的運行。

img

如此一來,通過傳遞Context就可以追蹤goroutine調用樹,並在這些調用樹之間傳遞通知和元數據。
雖然goroutine之間是平行的,沒有繼承關系,但是Context設計成是包含父子關系的,這樣可以更好的描述goroutine調用之間的樹型關系。

1.3 context結構

// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this Context is canceled
    // or times out.
    Done() <-chan struct{}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

👆🏻golang Context的源碼,Context是一個Interface,有四個方法,Done(),Err(),Deadline,Value

字段 含義
Deadline 返回一個time.Time,表示當前Context應該結束的時間,ok則表示有結束時間
Done 當Context被取消或者超時時候返回的一個close的channel,告訴給context相關的函數要停止當前工作然后返回了。(這個有點像全局廣播)
Err context被取消的原因
Value context實現共享數據存儲的地方,是協程安全的。

同時包中也定義了提供cancel功能需要實現的接口。

// A canceler is a context type that can be canceled directly. The
// implementations are *cancelCtx and *timerCtx.
type canceler interface {
	cancel(removeFromParent bool, err error)
	Done() <-chan struct{}
}

go的context庫提供了四種實現,如下

  • emptyCtx:

    type emptyCtx int
    

    完全空的Context,實現的函數也都是返回nil,僅僅只是實現了Context的接口

  • cancelCtx:

    // A cancelCtx can be canceled. When canceled, it also cancels any children
    // that implement canceler.
    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
    }
    

    繼承自Context,同時也實現了canceler接口。

  • timerCtx

    // A timerCtx carries a timer and a deadline. It embeds a cancelCtx to
    // implement Done and Err. It implements cancel by stopping its timer then
    // delegating to cancelCtx.cancel.
    type timerCtx struct {
    	cancelCtx
    	timer *time.Timer // Under cancelCtx.mu.
    
    	deadline time.Time
    }
    

    繼承自cancelCtx,增加了timeout機制。

  • valueCtx

// A valueCtx carries a key-value pair. It implements Value for that key and
// delegates all other calls to the embedded Context.
type valueCtx struct {
	Context
	key, val interface{}
}

存儲鍵值對的數據。

1.4 context的創建

為了更方便的創建Context,包里頭定義了Background來作為所有Context的根,它是一個emptyCtx的實例。

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

// Background returns a non-nil, empty Context. It is never canceled, has no
// values, and has no deadline. It is typically used by the main function,
// initialization, and tests, and as the top-level Context for incoming
// requests.
func Background() Context {
	return background
}

你可以認為所有的Context是樹的結構,Background是樹的根,當任一Context被取消的時候,那么繼承它的Context 都將被回收。

1.5 如何將 context 集成到 API 中?

在將 context 集成到 API 中時,要記住的最重要的一點是,它的作用域是請求級別 的。例如,沿單個數據庫查詢存在是有意義的,但沿數據庫對象存在則沒有意義。
目前有兩種方法可以將 context 對象集成到 API 中:

  • The first parameter of a function call

    首參數傳遞 context 對象,比如,參考 net 包 Dialer.DialContext。此函數執行正常的 Dial 操作,但可以通過 context 對象取消函數調用。

    func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error) {}
    
  • Optional config on a request structure

    在第一個 request 對象中攜帶一個可選的 context 對象。例如 net/http 庫的 Request.WithContext,通過攜帶給定的 context 對象,返回一個新的 Request 對象。

    func (d *Request) WithContext(ctx context.Context) *Request
    

1.6 Do not store Contexts inside a struct type

Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.The Context should be the first parameter, typically named ctx:

func DoSth(ctx context.Context, arg Arg) error {
	// ... use ctx ...
}

Incoming requests to a server should create a Context.

使用 context 的一個很好的心智模型是它應該在程序中流動,應該貫穿你的代碼。這通常意味着您不希望將其存儲在結構體之中。它從一個函數傳遞到另一個函數,並根據需要進行擴展。理想情況下,每個請求都會創建一個 context 對象,並在請求結束時過期。
不存儲上下文的一個例外是,當您需要將它放入一個結構中時,該結構純粹用作通過通道傳遞的消息。如下例所示。

// A message processes parameter and returns the result on responseChan.
// ctx is place in a struct, but this is ok to do.
type message struct {
	response  chan<- int
	parameter string
	ctx       context.Context
}

1.7 context.WithValue

在1.3小結有描述valueCtx的結構。

為了實現不斷的 WithValue,構建新的 context,內部在查找 key 時候,使用遞歸方式不斷從當前,從父節點尋找匹配的 key(鏈表的方式去尋找,a能找a.Value,b能找b.Value與a.Value…),直到 root context(Backgrond 和 TODO Value 函數會返回 )。其實現了隔離性,但是性能會損耗更多,所以其不適合大量的去調用。

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

比如我們新建了一個基於 context.Background() 的 ctx1,攜帶了一個 map 的數據,map 中包含了 “k1”: “v1” 的一個鍵值對,ctx1 被兩個 goroutine 同時使用作為函數簽名傳入,如果我們修改了 這個map,會導致另外進行讀 context.Value 的 goroutine 和修改 map 的 goroutine,在 map 對象上產生 data race。因此我們要使用 copy-on-write 的思路,解決跨多個 goroutine 使用數據、修改數據的場景。

Replace a Context using WithCancel, WithDeadline, WithTimeout, or WithValue.

1.7.1 The chain of function calls between them must propagate the Context

COW: 從 ctx1 中獲取 map1(可以理解為 v1 版本的 map 數據)。構建一個新的 map 對象 map2,復制所有 map1 數據,同時追加新的數據 “k2”: “v2” 鍵值對,使用 context.WithValue 創建新的 ctx2,ctx2 會傳遞到其他的 goroutine 中。這樣各自讀取的副本都是自己的數據,寫行為追加的數據,在 ctx2 中也能完整讀取到,同時也不會污染 ctx1 中的數據。

1.8 When a Context is canceled, all Contexts derived from it are also canceled

當一個 context 被取消時,從它派生的所有 context 也將被取消。WithCancel(ctx) 參數 ctx 認為是 parent ctx,在內部會進行一個傳播關系鏈的關聯。Done() 返回 一個 chan,當我們取消某個parent context, 實際上上會遞歸層層 cancel 掉自己的 child context 的 done chan 從而讓整個調用鏈中所有監聽 cancel 的 goroutine 退出。

func main() {
	gen := func(ctx context.Context) <-chan int {
		dst := make(chan int)
		n := 1
		go func() {
			for {
				select {
				case <-ctx.Done():
					return // return 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
		}
	}
}

1.9 Debugging or tracing data is safe to pass in a Context

context.WithValue 方法允許上下文攜帶請求范圍的數據。這些數據必須是安全的,以便多個 goroutine 同時使用。這里的數據,更多是面向請求的元數據,不應該作為函數的可選參數來使用(比如 context 里面掛了一個sql.Tx 對象,傳遞到 Dao 層使用),因為元數據相對函數參數更加是隱含的,面向請求的。而參數是更加顯示的。

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}
}

同一個 context 對象可以傳遞給在不同 goroutine 中運行的函數使用;上下文對於多個 goroutine 同時使用是安全的(就是說放進去的值是不能被篡改的)。如果有的的確確需要修改的值,就需要使用COW思想,新建一個值將原來的deep copy,然后將里面的值修改再掛一個新的值就搞定了(即生成一個新的值往下傳,這樣才是無污染的)。對於值類型最容易犯錯的地方,在於 context value 應該是 immutable 的,每次重新賦值應該是新的 context,即: context.WithValue(ctx, oldvalue)
https://pkg.go.dev/google.golang.org/grpc/metadata
Context.Value should inform, not control

Use context values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
比如 染色,API 重要性,Trace
https://github.com/go-kratos/kratos/blob/master/pkg/net/metadata/key.go

1.10 All blocking/long operations should be cancelable

如果要實現一個超時控制,通過上面的 context 的 parent/child 機制,其實我們只需要啟動一個定時器,然后在超時的時候,直接將當前的 context 給 cancel 掉,就可以實現監聽在當前和下層的額 context.Done() 的 goroutine 的退出。

package main

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

const shortDuration = 1 * time.Millisecond

func main() {
	d := time.Now().Add(shortDuration)
	ctx, cancel := context.WithDeadline(context.Background(), d)

	defer cancel()	// 避免泄漏
	select {
	case <-time.After(1 * time.Second):
		fmt.Println("overslept")
	case <-ctx.Done():
		fmt.Println(ctx.Err())
	}
}

2. 總結

通過引入Context包,一個request范圍內所有goroutine運行時的取消可以得到有效的控制。但是這種解決方式卻不夠優雅。一旦代碼中某處用到了Context,傳遞Context變量(通常作為函數的第一個參數)會像病毒一樣蔓延在各處調用它的地方。比如在一個request中實現數據庫事務或者分布式日志記錄,創建的context,會作為參數傳遞到任何有數據庫操作或日志記錄需求的函數代碼處。即每一個相關函數都必須增加一個context.Context類型的參數,且作為第一個參數,這對無關代碼完全是侵入式的。

更多詳細內容可參見:Michal Strba 的context-should-go-away-go2文章

Context機制最核心的功能是在goroutine之間傳遞cancel信號,但是它的實現是不完全的。

Cancel可以細分為主動與被動兩種,通過傳遞context參數,讓調用goroutine可以主動cancel被調用goroutine。但是如何得知被調用goroutine什么時候執行完畢,這部分Context機制是沒有實現的。而現實中的確又有一些這樣的場景,比如一個組裝數據的goroutine必須等待其他goroutine完成才可開始執行,這是context明顯不夠用了,必須借助sync.WaitGroup。

func serve(l net.Listener) error {
        var wg sync.WaitGroup
        var conn net.Conn
        var err error
        for {
                conn, err = l.Accept()
                if err != nil {
                        break
                }
                wg.Add(1)
                go func(c net.Conn) {
                        defer wg.Done()
                        handle(c)
                }(conn)
        }
        wg.Wait()
        return err
}


免責聲明!

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



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