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