最近在項目開發時,經常使用到 Context 這個包。 context.Context 是Go語言中獨特的設計,在其他程式語言中我們很少見到類似的概念。所以這一期我們就來好好講一講 Context 的基本概念與實際使用,麻麻再也不擔心我的並發編程啦~~~。
在理解 context 包之前,我們應該熟悉兩個概念,因為這能加深你對 context 的理解。
Goroutine 是一個輕量級的執行線程,多個 Goroutine 比一個線程輕量所以管理他們消耗的資源相對更少。 Goroutine 是Go中最基本的執行單元,每一個Go程序至少有一個 Goroutine :主 Goroutine 。程序啟動時會自動創建。這裡為了大家能更好的理解 Goroutine ,我們先來看一看線程與協程的概念。
線程是一種輕量級進程,是CPU調度的最小單位。一個標準的線程由線程ID,當前指令指針(PC),寄存器集合和堆棧組成。線程是進程中的一個實體,是被系統獨立調度和分派的基本單位,線程自己不擁有系統資源,只擁有一點在運行中必不可少的資源,但它可與同屬於一個進程的其他線程共享進程所擁有的全部資源。線程擁有自己獨立的棧和共享的堆,共享堆,不共享棧,線程的切換一般也由作業系統調度。
又稱為微線程與子例程一樣,協程也是一種程序組建,相對子例程而言,協程更為靈活,但在實踐中使用沒有子例程那樣廣泛。和線程類似,共享堆,不共享棧,協程的切換一般由程式設計師在代碼中顯式控制。他避免了上下文切換的額外耗費,兼顧了多線程的優點,簡化了高並發程序的複雜。
Goroutine和其他語言的協程(coroutine)在使用方式上類似,但從字面意義上來看不同(一個是Goroutine,一個是coroutine),再就是協程是一種協作任務控制機制,在最簡單的意義上,協程不是並發的,而Goroutine支持並發的。因此Goroutine可以理解為一種Go語言的協程。同時它可以運行在一個或多個線程上。
我們來看一個簡單示例:
func Hello() { fmt.Println(&39;m asong&34;Golang夢工廠&FEFEFE; --tt-darkmode-bgcolor: F7F7F9; --tt-darkmode-bgcolor: F7F7F9; --tt-darkmode-bgcolor: FEFEFE; --tt-darkmode-bgcolor: F7F7F9; --tt-darkmode-bgcolor: F7F7F9; --tt-darkmode-bgcolor: F7F7F9; --tt-darkmode-bgcolor: F7F7F9; --tt-darkmode-bgcolor: F7F7F9; --tt-darkmode-bgcolor: F7F7F9; --tt-darkmode-bgcolor: FEFEFE; --tt-darkmode-bgcolor: FEFEFE; --tt-darkmode-bgcolor: FEFEFE; --tt-darkmode-bgcolor: FEFEFE; --tt-darkmode-bgcolor: 34;hello everybody , I&34;) ch <- 1}func main() { ch := make(chan int) go Hello(ch) <-ch fmt.Println(&34;)}
這裡我們使用通道進行等待,這樣 main 就會等待 goroutine 執行完。現在我們知道了 goroutine 、 channel 的概念了,下面我們就來介紹一下 context 。
有了上面的概念,我們在來看一個例子:
如下代碼,每次請求,Handler會創建一個goroutine來為其提供服務,而且連續請求3次, request 的地址也是不同的:
func main() { http.HandleFunc(&34;, SayHello) // 設置訪問的路由 log.Fatalln(http.ListenAndServe(&34;,nil))}func SayHello(writer http.ResponseWriter, request *http.Request) { fmt.Println(&request) writer.Write([]byte(&34;))}========================================================$ curl http://localhost:8080/0xc0000b80300xc0001860080xc000186018
而每個請求對應的Handler,常會啟動額外的的goroutine進行數據查詢或PRC調用等。
而當請求返回時,這些額外創建的goroutine需要及時回收。而且,一個請求對應一組請求域內的數據可能會被該請求調用鏈條內的各goroutine所需要。
現在我們對上面代碼在添加一點東西,當請求進來時, Handler 創建一個監控 goroutine ,這樣就會每隔1s列印一句 Current request is in progress
func main() { http.HandleFunc(&34;, SayHello) // 設置訪問的路由 log.Fatalln(http.ListenAndServe(&34;,nil))}func SayHello(writer http.ResponseWriter, request *http.Request) { fmt.Println(&request) go func() { for range time.Tick(time.Second) { fmt.Println(&34;) } }() time.Sleep(2 * time.Second) writer.Write([]byte(&34;))}
這裡我假定請求需要耗時2s,在請求2s後返回,我們期望監控goroutine在列印2次 Current request is in progress 後即停止。但運行發現,監控goroutine列印2次後,其仍不會結束,而會一直列印下去。
問題出在創建監控goroutine後,未對其生命周期作控制,下面我們使用context作一下控制,即監控程序列印前需檢測 request.Context() 是否已經結束,若結束則退出循環,即結束生命周期。
func main() { http.HandleFunc(&34;, SayHello) // 設置訪問的路由 log.Fatalln(http.ListenAndServe(&34;,nil))}func SayHello(writer http.ResponseWriter, request *http.Request) { fmt.Println(&request) go func() { for range time.Tick(time.Second) { select { case <- request.Context().Done(): fmt.Println(&34;) return default: fmt.Println(&34;) } } }() time.Sleep(2 * time.Second) writer.Write([]byte(&34;))}
基於如上需求,context包應用而生。context包可以提供一個請求從API請求邊界到各goroutine的請求域數據傳遞、取消信號及截至時間等能力。詳細原理請看下文。
在 Go 語言中 context 包允許您傳遞一個 &34; 到您的程序。 Context 如超時或截止日期(deadline)或通道,來指示停止運行和返回。例如,如果您正在執行一個 web 請求或運行一個系統命令,定義一個超時對生產級系統通常是個好主意。因為,如果您依賴的API運行緩慢,你不希望在系統上備份(back up)請求,因為它可能最終會增加負載並降低所有請求的執行效率。導致級聯效應。這是超時或截止日期 context 派上用場的地方。
Go 語言中的每一個請求的都是通過一個單獨的 Goroutine 進行處理的,HTTP/RPC 請求的處理器往往都會啟動新的 Goroutine 訪問資料庫和 RPC 服務,我們可能會創建多個 Goroutine 來處理一次請求,而 Context 的主要作用就是在不同的 Goroutine 之間同步請求特定的數據、取消信號以及處理請求的截止日期。
每一個 Context 都會從最頂層的 Goroutine 一層一層傳遞到最下層,這也是 Golang 中上下文最常見的使用方式,如果沒有 Context ,當上層執行的操作出現錯誤時,下層其實不會收到錯誤而是會繼續執行下去。
當最上層的 Goroutine 因為某些原因執行失敗時,下兩層的 Goroutine 由於沒有接收到這個信號所以會繼續工作;但是當我們正確地使用 Context 時,就可以在下層及時停掉無用的工作減少額外資源的消耗:
這其實就是 Golang 中上下文的最大作用,在不同 Goroutine 之間對信號進行同步避免對計算資源的浪費,與此同時 Context 還能攜帶以請求為作用域的鍵值對信息。
這裡光說,其實也不能完全理解其中的作用,所以我們來看一個例子:
func main() { ctx,cancel := context.WithTimeout(context.Background(),1 * time.Second) defer cancel() go HelloHandle(ctx,500*time.Millisecond) select { case <- ctx.Done(): fmt.Println(&34;,ctx.Err()) }}func HelloHandle(ctx context.Context,duration time.Duration) { select { case <-ctx.Done(): fmt.Println(ctx.Err()) case <-time.After(duration): fmt.Println(&34;, duration) }}
上面的代碼,因為過期時間大於處理時間,所以我們有足夠的時間處理改請求,所以運行代碼如下圖所示:
process request with 500msHello Handle context deadline exceeded
HelloHandle 函數並沒有進入超時的 select 分支,但是 main 函數的 select 卻會等待 context.Context 的超時並列印出 Hello Handle context deadline exceeded 。如果我們將處理請求的時間增加至 2000 ms,程序就會因為上下文過期而被終止。
context deadline exceededHello Handle context deadline exceeded
context.Context 是 Go 語言在 1.7 版本中引入標準庫的接口 1 ,該接口定義了四個需要實現的方法,其中包括:
type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key interface{}) interface{}}
context 包允許一下方式創建和獲得 context :
其實我們查看原始碼。發現他倆都是通過 new(emptyCtx) 語句初始化的,它們是指向私有結構體 context.emptyCtx 的指針,這是最簡單、最常用的上下文類型:
var ( background = new(emptyCtx) todo = new(emptyCtx))type emptyCtx intfunc (*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}func (e *emptyCtx) String() string { switch e { case background: return &34; case todo: return &34; } return &34;}
從上述代碼,我們不難發現 context.emptyCtx 通過返回 nil 實現了 context.Context 接口,它沒有任何特殊的功能。
從原始碼來看, context.Background 和 context.TODO 函數其實也只是互為別名,沒有太大的差別。它們只是在使用和語義上稍有不同:
在多數情況下,如果當前函數沒有上下文作為入參,我們都會使用 context.Background 作為起始的上下文向下傳遞。
有了如上的根Context,那麼是如何衍生更多的子Context的呢?這就要靠context包為我們提供的 With 系列的函數了。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)func WithValue(parent Context, key, val interface{}) Context
這四個 With 函數,接收的都有一個partent參數,就是父Context,我們要基於這個父Context創建出子Context的意思,這種方式可以理解為子Context對父Context的繼承,也可以理解為基於父Context的衍生。
通過這些函數,就創建了一顆Context樹,樹的每個節點都可以有任意多個子節點,節點層級可以有任意多個。
WithCancel 函數,傳遞一個父Context作為參數,返回子Context,以及一個取消函數用來取消Context。 WithDeadline 函數,和 WithCancel 差不多,它會多傳遞一個截止時間參數,意味著到了這個時間點,會自動取消Context,當然我們也可以不等到這個時候,可以提前通過取消函數進行取消。
WithTimeout 和 WithDeadline 基本上一樣,這個表示是超時自動取消,是多少時間後自動取消Context的意思。
WithValue 函數和取消Context無關,它是為了生成一個綁定了一個鍵值對數據的Context,這個綁定的數據可以通過 Context.Value 方法訪問到,後面我們會專門講。
大家可能留意到,前三個函數都返回一個取消函數 CancelFunc ,這是一個函數類型,它的定義非常簡單。
type CancelFunc func()
這就是取消函數的類型,該函數可以取消一個Context,以及這個節點Context下所有的所有的Context,不管有多少層級。
下面我就展開來介紹一個每一個方法的使用。
context 包中的 context.WithValue 函數能從父上下文中創建一個子上下文,傳值的子上下文使用 context.valueCtx 類型,我們看一下源碼:
// WithValue returns a copy of parent in which the value associated with key is// val.//// Use context Values only for request-scoped data that transits processes and// APIs, not for passing optional parameters to functions.//// The provided key must be comparable and should not be of type// string or any other built-in type to avoid collisions between// packages using context. Users of WithValue should define their own// types for keys. To avoid allocating when assigning to an// interface{}, context keys often have concrete type// struct{}. Alternatively, exported context key variables&34;nil key&34;key is not comparable&39;t// want context depending on the unicode tables. This is only used by// *valueCtx.String().func stringify(v interface{}) string { switch s := v.(type) { case stringer: return s.String() case string: return s } return &34;}func (c *valueCtx) String() string { return contextName(c.Context) + &34; + reflectlite.TypeOf(c.key).String() + &34; + stringify(c.val) + &34;}func (c *valueCtx) Value(key interface{}) interface{} { if c.key == key { return c.val } return c.Context.Value(key)}
此函數接收 context 並返回派生 context,其中值 val 與 key 關聯,並通過 context 樹與 context 一起傳遞。這意味著一旦獲得帶有值的 context,從中派生的任何 context 都會獲得此值。不建議使用 context 值傳遞關鍵參數,而是函數應接收籤名中的那些值,使其顯式化。
context.valueCtx 結構體會將除了 Value 之外的 Err 、 Deadline 等方法代理到父上下文中,它只會響應 context.valueCtx.Value 方法。如果 context.valueCtx 中存儲的鍵值對與 context.valueCtx.Value 方法中傳入的參數不匹配,就會從父上下文中查找該鍵對應的值直到在某個父上下文中返回 nil 或者查找到對應的值。
說了這麼多,比較枯燥,我們來看一下怎麼使用:
type key stringfunc main() { ctx := context.WithValue(context.Background(),key(&34;),&34;) Get(ctx,key(&34;)) Get(ctx,key(&34;))}func Get(ctx context.Context,k key) { if v, ok := ctx.Value(k).(string); ok { fmt.Println(v) }}
上面代碼我們基於 context.Background 創建一個帶值的ctx,然後可以根據key來取值。這裡為了避免多個包同時使用 context 而帶來衝突, key 不建議使用 string 或其他內置類型,所以建議自定義 key 類型.
此函數創建從傳入的父 context 派生的新 context。父 context 可以是後臺 context 或傳遞給函數的 context。返回派生 context 和取消函數。只有創建它的函數才能調用取消函數來取消此 context。如果您願意,可以傳遞取消函數,但是,強烈建議不要這樣做。這可能導致取消函數的調用者沒有意識到取消 context 的下遊影響。可能存在源自此的其他 context,這可能導致程序以意外的方式運行。簡而言之,永遠不要傳遞取消函數。
我們直接從 context.WithCancel 函數的實現來看它到底做了什麼:
// WithCancel returns a copy of parent with a new Done channel. The returned// context&39;s Done channel is closed, whichever happens first.//// Canceling this context releases resources associated with it, so code should// call cancel as soon as the operations running in this Context complete.func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { c := newCancelCtx(parent) propagateCancel(parent, &c) return &c, func() { c.cancel(true, Canceled) }}// newCancelCtx returns an initialized cancelCtx.func newCancelCtx(parent Context) cancelCtx { return cancelCtx{Context: parent}}
func propagateCancel(parent Context, child canceler) { done := parent.Done() if done == nil { return // 父上下文不會觸發取消信號 } select { case <-done: child.cancel(false, parent.Err()) // 父上下文已經被取消 return default: } if p, ok := parentCancelCtx(parent); ok { p.mu.Lock() if p.err != nil { child.cancel(false, p.err) } else { p.children[child] = struct{}{} } p.mu.Unlock() } else { go func() { select { case <-parent.Done(): child.cancel(false, parent.Err()) case <-child.Done(): } }() }}
上述函數總共與父上下文相關的三種不同的情況:
context.propagateCancel 的作用是在 parent 和 child 之間同步取消和結束的信號,保證在 parent 被取消時, child 也會收到對應的信號,不會發生狀態不一致的問題。
context.cancelCtx 實現的幾個接口方法也沒有太多值得分析的地方,該結構體最重要的方法是 cancel ,這個方法會關閉上下文中的 Channel 並向所有的子上下文同步取消信號:
func (c *cancelCtx) cancel(removeFromParent bool, err error) { c.mu.Lock() if c.err != nil { c.mu.Unlock() return } c.err = err if c.done == nil { c.done = closedchan } else { close(c.done) } for child := range c.children { child.cancel(false, err) } c.children = nil c.mu.Unlock() if removeFromParent { removeChild(c.Context, c) }}
說了這麼,看一例子,帶你感受一下使用方法:
func main() { ctx,cancel := context.WithCancel(context.Background()) defer cancel() go Speak(ctx) time.Sleep(10*time.Second)}func Speak(ctx context.Context) { for range time.Tick(time.Second){ select { case <- ctx.Done(): return default: fmt.Println(&34;) } }}
我們使用 withCancel 創建一個基於 Background 的ctx,然後啟動一個講話程序,每隔1s說一話, main 函數在10s後執行 cancel ,那麼 speak 檢測到取消信號就會退出。
此函數返回其父項的派生 context,當截止日期超過或取消函數被調用時,該 context 將被取消。例如,您可以創建一個將在以後的某個時間自動取消的 context,並在子函數中傳遞它。當因為截止日期耗盡而取消該 context 時,獲此 context 的所有函數都會收到通知去停止運行並返回。
我們來看一下源碼:
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) { if cur, ok := parent.Deadline(); ok && cur.Before(d) { return WithCancel(parent) } c := &timerCtx{ cancelCtx: newCancelCtx(parent), deadline: d, } propagateCancel(parent, c) dur := time.Until(d) if dur <= 0 { c.cancel(true, DeadlineExceeded) // 已經過了截止日期 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) }}
context.WithDeadline 也都能創建可以被取消的計時器上下文 context.timerCtx :
context.WithDeadline 方法在創建 context.timerCtx 的過程中,判斷了父上下文的截止日期與當前日期,並通過 time.AfterFunc 創建定時器,當時間超過了截止日期後會調用 context.timerCtx.cancel 方法同步取消信號。
context.timerCtx 結構體內部不僅通過嵌入了 context.cancelCtx 結構體繼承了相關的變量和方法,還通過持有的定時器 timer 和截止時間 deadline 實現了定時取消這一功能:
type timerCtx struct { cancelCtx timer *time.Timer // Under cancelCtx.mu. deadline time.Time}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()}
context.timerCtx.cancel 方法不僅調用了 context.cancelCtx.cancel ,還會停止持有的定時器減少不必要的資源浪費。
接下來我們來看一個例子:
func main() { now := time.Now() later,_:=time.ParseDuration(&34;) ctx,cancel := context.WithDeadline(context.Background(),now.Add(later)) defer cancel() go Monitor(ctx) time.Sleep(20 * time.Second)}func Monitor(ctx context.Context) { select { case <- ctx.Done(): fmt.Println(ctx.Err()) case <-time.After(20*time.Second): fmt.Println(&34;) }}
設置一個監控 goroutine ,使用WithTimeout創建一個基於Background的ctx,其會當前時間的10s後取消。驗證結果如下:
context deadline exceeded
10s,監控 goroutine 被取消了。
此函數類似於 context.WithDeadline。不同之處在於它將持續時間作為參數輸入而不是時間對象。此函數返回派生 context,如果調用取消函數或超出超時持續時間,則會取消該派生 context。
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { return WithDeadline(parent, time.Now().Add(timeout))}
觀看源碼我們可以看出 WithTimeout 內部調用的就是 WithDeadline ,其原理都是一樣的,上面已經介紹過了,來看一個例子吧:
func main() { ctx,cancel := context.WithTimeout(context.Background(),10 * time.Second) defer cancel() go Monitor(ctx) time.Sleep(20 * time.Second)}func Monitor(ctx context.Context) { select { case <- ctx.Done(): fmt.Println(ctx.Err()) case <-time.After(20*time.Second): fmt.Println(&34;) }}
Go 語言中的 context.Context 的主要作用還是在多個 Goroutine 組成的樹中同步取消信號以減少對資源的消耗和佔用,雖然它也有傳值的功能,但是這個功能我們還是很少用到。在真正使用傳值的功能時我們也應該非常謹慎,使用 context.Context 進行傳遞參數請求的所有參數一種非常差的設計,比較常見的使用場景是傳遞請求對應用戶的認證令牌以及用於進行分布式追蹤的請求 ID。
好啦,這一期文章到這裡就結束啦。為了弄懂這裡,參考很多文章,會在結尾貼出來,供大家學習參考。因為這個包真的很重要,在平常項目開發中我們也是經常使用到,所以大家弄懂 context 的原理還是很有必要的。
文章的示例代碼已上傳github:
https://github.com/asong2020/Golang_Dream/tree/master/code_demo/context_example