前言
哈嘍,大家好,我是asong
。今天想與大家分享context
包,經過一年的沉澱,重新出發,基於Go1.17.1
從原始碼角度再次分析,不過這次不同的是,我打算先從入門開始,因為大多數初學的讀者都想先知道怎麼用,然後才會關心原始碼是如何實現的。
相信大家在日常工作開發中一定會看到這樣的程式碼:
func a1(ctx context ...){
b1(ctx)
}
func b1(ctx context ...){
c1(ctx)
}
func c1(ctx context ...)
context
被當作第一個引數(官方建議),並且不斷透傳下去,基本一個專案程式碼中到處都是context
,但是你們真的知道它有何作用嗎以及它是如何起作用的嗎?我記得我第一次接觸context
時,同事都說這個用來做併發控制的,可以設定超時時間,超時就會取消往下執行,快速返回,我就單純的認為只要函式中帶著context
引數往下傳遞就可以做到超時取消,快速返回。相信大多數初學者也都是和我一個想法,其實這是一個錯誤的思想,其取消機制採用的也是通知機制,單純的透傳並不會起作用,比如你這樣寫程式碼:
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) {
for {
fmt.Print("monitor")
}
}
即使context
透傳下去了,不使用也是不起任何作用的。所以瞭解context
的使用還是很有必要的,本文就先從使用開始,逐步解析Go
語言的context
包,下面我們就開始嘍!!!
context
包的起源與作用
看官方部落格我們可以知道context
包是在go1.7
版本中引入到標準庫中的:
<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c0d332914b0c44ae8706589eaef6ebaa~tplv-k3u1fbpfcp-zoom-1.image" style="zoom:50%;" />
context
可以用來在goroutine
之間傳遞上下文資訊,相同的context
可以傳遞給執行在不同goroutine
中的函式,上下文對於多個goroutine
同時使用是安全的,context
包定義了上下文型別,可以使用background
、TODO
建立一個上下文,在函式呼叫鏈之間傳播context
,也可以使用WithDeadline
、WithTimeout
、WithCancel
或 WithValue
建立的修改副本替換它,聽起來有點繞,其實總結起就是一句話:context
的作用就是在不同的goroutine
之間同步請求特定的資料、取消訊號以及處理請求的截止日期。
目前我們常用的一些庫都是支援context
的,例如gin
、database/sql
等庫都是支援context
的,這樣更方便我們做併發控制了,只要在伺服器入口建立一個context
上下文,不斷透傳下去即可。
context
的使用
建立context
context
包主要提供了兩種方式建立context
:
context.Backgroud()
context.TODO()
這兩個函式其實只是互為別名,沒有差別,官方給的定義是:
context.Background
是上下文的預設值,所有其他的上下文都應該從它衍生(Derived)出來。context.TODO
應該只在不確定應該使用哪種上下文時使用;
所以在大多數情況下,我們都使用context.Background
作為起始的上下文向下傳遞。
上面的兩種方式是建立根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
這四個函式都要基於父Context
衍生,通過這些函式,就建立了一顆Context樹,樹的每個節點都可以有任意多個子節點,節點層級可以有任意多個,畫個圖表示一下:
基於一個父Context
可以隨意衍生,其實這就是一個Context
樹,樹的每個節點都可以有任意多個子節點,節點層級可以有任意多個,每個子節點都依賴於其父節點,例如上圖,我們可以基於Context.Background
衍生出四個子context
:ctx1.0-cancel
、ctx2.0-deadline
、ctx3.0-timeout
、ctx4.0-withvalue
,這四個子context
還可以作為父context
繼續向下衍生,即使其中ctx1.0-cancel
節點取消了,也不影響其他三個父節點分支。
建立context
方法和context
的衍生方法就這些,下面我們就一個一個來看一下他們如何被使用。
WithValue
攜帶資料
我們日常在業務開發中都希望能有一個trace_id
能串聯所有的日誌,這就需要我們列印日誌時能夠獲取到這個trace_id
,在python
中我們可以用gevent.local
來傳遞,在java
中我們可以用ThreadLocal
來傳遞,在Go
語言中我們就可以使用Context
來傳遞,通過使用WithValue
來建立一個攜帶trace_id
的context
,然後不斷透傳下去,列印日誌時輸出即可,來看使用例子:
const (
KEY = "trace_id"
)
func NewRequestID() string {
return strings.Replace(uuid.New().String(), "-", "", -1)
}
func NewContextWithTraceID() context.Context {
ctx := context.WithValue(context.Background(), KEY,NewRequestID())
return ctx
}
func PrintLog(ctx context.Context, message string) {
fmt.Printf("%s|info|trace_id=%s|%s",time.Now().Format("2006-01-02 15:04:05") , GetContextValue(ctx, KEY), message)
}
func GetContextValue(ctx context.Context,k string) string{
v, ok := ctx.Value(k).(string)
if !ok{
return ""
}
return v
}
func ProcessEnter(ctx context.Context) {
PrintLog(ctx, "Golang夢工廠")
}
func main() {
ProcessEnter(NewContextWithTraceID())
}
輸出結果:
2021-10-31 15:13:25|info|trace_id=7572e295351e478e91b1ba0fc37886c0|Golang夢工廠
Process finished with the exit code 0
我們基於context.Background
建立一個攜帶trace_id
的ctx
,然後通過context
樹一起傳遞,從中派生的任何context
都會獲取此值,我們最後列印日誌的時候就可以從ctx
中取值輸出到日誌中。目前一些RPC
框架都是支援了Context
,所以trace_id
的向下傳遞就更方便了。
在使用withVaule
時要注意四個事項:
- 不建議使用
context
值傳遞關鍵引數,關鍵引數應該顯示的宣告出來,不應該隱式處理,context
中最好是攜帶簽名、trace_id
這類值。 - 因為攜帶
value
也是key
、value
的形式,為了避免context
因多個包同時使用context
而帶來衝突,key
建議採用內建型別。 - 上面的例子我們獲取
trace_id
是直接從當前ctx
獲取的,實際我們也可以獲取父context
中的value
,在獲取鍵值對是,我們先從當前context
中查詢,沒有找到會在從父context
中查詢該鍵對應的值直到在某個父context
中返回nil
或者查詢到對應的值。 context
傳遞的資料中key
、value
都是interface
型別,這種型別編譯期無法確定型別,所以不是很安全,所以在型別斷言時別忘了保證程式的健壯性。
超時控制
通常健壯的程式都是要設定超時時間的,避免因為服務端長時間響應消耗資源,所以一些web
框架或rpc
框架都會採用withTimeout
或者withDeadline
來做超時控制,當一次請求到達我們設定的超時時間,就會及時取消,不在往下執行。withTimeout
和withDeadline
作用是一樣的,就是傳遞的時間引數不同而已,他們都會通過傳入的時間來自動取消Context
,這裡要注意的是他們都會返回一個cancelFunc
方法,通過呼叫這個方法可以達到提前進行取消,不過在使用的過程還是建議在自動取消後也呼叫cancelFunc
去停止定時減少不必要的資源浪費。
withTimeout
、WithDeadline
不同在於WithTimeout
將持續時間作為引數輸入而不是時間物件,這兩個方法使用哪個都是一樣的,看業務場景和個人習慣了,因為本質withTimout
內部也是呼叫的WithDeadline
。
現在我們就舉個例子來試用一下超時控制,現在我們就模擬一個請求寫兩個例子:
- 達到超時時間終止接下來的執行
func main() {
HttpHandler()
}
func NewContextWithTimeout() (context.Context,context.CancelFunc) {
return context.WithTimeout(context.Background(), 3 * time.Second)
}
func HttpHandler() {
ctx, cancel := NewContextWithTimeout()
defer cancel()
deal(ctx)
}
func deal(ctx context.Context) {
for i:=0; i< 10; i++ {
time.Sleep(1*time.Second)
select {
case <- ctx.Done():
fmt.Println(ctx.Err())
return
default:
fmt.Printf("deal time is %d\n", i)
}
}
}
輸出結果:
deal time is 0
deal time is 1
context deadline exceeded
- 沒有達到超時時間終止接下來的執行
func main() {
HttpHandler1()
}
func NewContextWithTimeout1() (context.Context,context.CancelFunc) {
return context.WithTimeout(context.Background(), 3 * time.Second)
}
func HttpHandler1() {
ctx, cancel := NewContextWithTimeout1()
defer cancel()
deal1(ctx, cancel)
}
func deal1(ctx context.Context, cancel context.CancelFunc) {
for i:=0; i< 10; i++ {
time.Sleep(1*time.Second)
select {
case <- ctx.Done():
fmt.Println(ctx.Err())
return
default:
fmt.Printf("deal time is %d\n", i)
cancel()
}
}
}
輸出結果:
deal time is 0
context canceled
使用起來還是比較容易的,既可以超時自動取消,又可以手動控制取消。這裡大家要記的一個坑,就是我們往從請求入口透傳的呼叫鏈路中的context
是攜帶超時時間的,如果我們想在其中單獨開一個goroutine去處理其他的事情並且不會隨著請求結束後而被取消的話,那麼傳遞的context
要基於context.Background
或者context.TODO
重新衍生一個傳遞,否決就會和預期不符合了,可以看一下我之前的一篇踩坑文章:context使用不當引發的一個bug。
withCancel
取消控制
日常業務開發中我們往往為了完成一個複雜的需求會開多個gouroutine
去做一些事情,這就導致我們會在一次請求中開了多個goroutine
確無法控制他們,這時我們就可以使用withCancel
來衍生一個context
傳遞到不同的goroutine
中,當我想讓這些goroutine
停止執行,就可以呼叫cancel
來進行取消。
來看一個例子:
func main() {
ctx,cancel := context.WithCancel(context.Background())
go Speak(ctx)
time.Sleep(10*time.Second)
cancel()
time.Sleep(1*time.Second)
}
func Speak(ctx context.Context) {
for range time.Tick(time.Second){
select {
case <- ctx.Done():
fmt.Println("我要閉嘴了")
return
default:
fmt.Println("balabalabalabala")
}
}
}
執行結果:
balabalabalabala
....省略
balabalabalabala
我要閉嘴了
我們使用withCancel
建立一個基於Background
的ctx,然後啟動一個講話程式,每隔1s說一話,main
函式在10s後執行cancel
,那麼speak
檢測到取消訊號就會退出。
自定義Context
因為Context
本質是一個介面,所以我們可以通過實現Context
達到自定義Context
的目的,一般在實現Web
框架或RPC
框架往往採用這種形式,比如gin
框架的Context
就是自己有封裝了一層,具體程式碼和實現就貼在這裡,有興趣可以看一下gin.Context
是如何實現的。
原始碼賞析
Context其實就是一個介面,定義了四個方法:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Deadlne
方法:當Context
自動取消或者到了取消時間被取消後返回Done
方法:當Context
被取消或者到了deadline
返回一個被關閉的channel
Err
方法:當Context
被取消或者關閉後,返回context
取消的原因Value
方法:獲取設定的key
對應的值
這個介面主要被三個類繼承實現,分別是emptyCtx
、ValueCtx
、cancelCtx
,採用匿名介面的寫法,這樣可以對任意實現了該介面的型別進行重寫。
下面我們就從建立到使用來層層分析。
建立根Context
其在我們呼叫context.Background
、context.TODO
時建立的物件就是empty
:
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
func Background() Context {
return background
}
func TODO() Context {
return todo
}
Background
和TODO
還是一模一樣的,官方說:background
它通常由主函式、初始化和測試使用,並作為傳入請求的頂級上下文;TODO
是當不清楚要使用哪個 Context 或尚不可用時,程式碼應使用 context.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
}
func (e *emptyCtx) String() string {
switch e {
case background:
return "context.Background"
case todo:
return "context.TODO"
}
return "unknown empty Context"
}
WithValue
的實現
withValue
內部主要就是呼叫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
類
valueCtx
目的就是為Context
攜帶鍵值對,因為它採用匿名介面的繼承實現方式,他會繼承父Context
,也就相當於嵌入Context
當中了
type valueCtx struct {
Context
key, val interface{}
}
實現了String
方法輸出Context
和攜帶的鍵值對資訊:
func (c *valueCtx) String() string {
return contextName(c.Context) + ".WithValue(type " +
reflectlite.TypeOf(c.key).String() +
", val " + stringify(c.val) + ")"
}
實現Value
方法來儲存鍵值對:
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
看圖來理解一下:
所以我們在呼叫Context
中的Value
方法時會層層向上呼叫直到最終的根節點,中間要是找到了key
就會返回,否會就會找到最終的emptyCtx
返回nil
。
WithCancel
的實現
我們來看一下WithCancel
的入口函式原始碼:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
c := newCancelCtx(parent)
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}
這個函式執行步驟如下:
- 建立一個
cancelCtx
物件,作為子context
- 然後呼叫
propagateCancel
構建父子context
之間的關聯關係,這樣當父context
被取消時,子context
也會被取消。 - 返回子
context
物件和子樹取消函式
我們先分析一下cancelCtx
這個類。
cancelCtx
類
cancelCtx
繼承了Context
,也實現了介面canceler
:
type cancelCtx struct {
Context
mu sync.Mutex // protects following fields
done atomic.Value // of 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
}
字短解釋:
mu
:就是一個互斥鎖,保證併發安全的,所以context
是併發安全的done
:用來做context
的取消通知訊號,之前的版本使用的是chan struct{}
型別,現在用atomic.Value
做鎖優化children
:key
是介面型別canceler
,目的就是儲存實現當前canceler
介面的子節點,當根節點發生取消時,遍歷子節點傳送取消訊號error
:當context
取消時儲存取消資訊
這裡實現了Done
方法,返回的是一個只讀的channel
,目的就是我們在外部可以通過這個阻塞的channel
等待通知訊號。
具體程式碼就不貼了。我們先返回去看propagateCancel
是如何做構建父子Context
之間的關聯。
propagateCancel
方法
程式碼有點長,解釋有點麻煩,我把註釋新增到程式碼中看起來比較直觀:
func propagateCancel(parent Context, child canceler) {
// 如果返回nil,說明當前父`context`從來不會被取消,是一個空節點,直接返回即可。
done := parent.Done()
if done == nil {
return // parent is never canceled
}
// 提前判斷一個父context是否被取消,如果取消了也不需要構建關聯了,
// 把當前子節點取消掉並返回
select {
case <-done:
// parent is already canceled
child.cancel(false, parent.Err())
return
default:
}
// 這裡目的就是找到可以“掛”、“取消”的context
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
// 找到了可以“掛”、“取消”的context,但是已經被取消了,那麼這個子節點也不需要
// 繼續掛靠了,取消即可
if p.err != nil {
child.cancel(false, p.err)
} else {
// 將當前節點掛到父節點的childrn map中,外面呼叫cancel時可以層層取消
if p.children == nil {
// 這裡因為childer節點也會變成父節點,所以需要初始化map結構
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 沒有找到可“掛”,“取消”的父節點掛載,那麼就開一個goroutine
atomic.AddInt32(&goroutines, +1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
這段程式碼真正產生疑惑的是這個if、else分支。不看程式碼了,直接說為什麼吧。因為我們可以自己定製context
,把context
塞進一個結構時,就會導致找不到可取消的父節點,只能重新起一個協程做監聽。
對這塊有迷惑的推薦閱讀饒大大文章:[深度解密Go語言之context](https://www.cnblogs.com/qcrao...),定能為你排憂解惑。
cancel
方法
最後我們再來看一下返回的cancel
方法是如何實現,這個方法會關閉上下文中的 Channel 並向所有的子上下文同步取消訊號:
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
// 取消時傳入的error資訊不能為nil, context定義了預設error:var Canceled = errors.New("context canceled")
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
// 用來關閉channel,通知其他協程
d, _ := c.done.Load().(chan struct{})
if d == nil {
c.done.Store(closedchan)
} else {
close(d)
}
// 當前節點向下取消,遍歷它的所有子節點,然後取消
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
child.cancel(false, err)
}
// 節點置空
c.children = nil
c.mu.Unlock()
// 把當前節點從父節點中移除,只有在外部父節點呼叫時才會傳true
// 其他都是傳false,內部呼叫都會因為c.children = nil被剔除出去
if removeFromParent {
removeChild(c.Context, c)
}
}
到這裡整個WithCancel
方法原始碼就分析好了,通過原始碼我們可以知道cancel
方法可以被重複呼叫,是冪等的。
withDeadline
、WithTimeout
的實現
先看WithTimeout
方法,它內部就是呼叫的WithDeadline
方法:
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
所以我們重點來看withDeadline
是如何實現的:
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
// 不能為空`context`建立衍生context
if parent == nil {
panic("cannot create context from nil parent")
}
// 當父context的結束時間早於要設定的時間,則不需要再去單獨處理子節點的定時器了
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// The current deadline is already sooner than the new one.
return WithCancel(parent)
}
// 建立一個timerCtx物件
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) }
}
withDeadline
相較於withCancel
方法也就多了一個定時器去定時呼叫cancel
方法,這個cancel
方法在timerCtx
類中進行了重寫,我們先來看一下timerCtx
類,他是基於cancelCtx
的,多了兩個欄位:
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
timerCtx
實現的cancel
方法,內部也是呼叫了cancelCtx
的cancel
方法取消:
func (c *timerCtx) cancel(removeFromParent bool, err error) {
// 呼叫cancelCtx的cancel方法取消掉子節點context
c.cancelCtx.cancel(false, err)
// 從父context移除放到了這裡來做
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()
}
終於原始碼部分我們就看完了,現在你何感想?
context
的優缺點
context
包被設計出來就是做併發控制的,這個包有利有弊,個人總結了幾個優缺點,歡迎評論區補充。
缺點
- 影響程式碼美觀,現在基本所有
web
框架、RPC
框架都是實現了context
,這就導致我們的程式碼中每一個函式的一個引數都是context
,即使不用也要帶著這個引數透傳下去,個人覺得有點醜陋。 context
可以攜帶值,但是沒有任何限制,型別和大小都沒有限制,也就是沒有任何約束,這樣很容易導致濫用,程式的健壯很難保證;還有一個問題就是通過context
攜帶值不如顯式傳值舒服,可讀性變差了。- 可以自定義
context
,這樣風險不可控,更加會導致濫用。 context
取消和自動取消的錯誤返回不夠友好,無法自定義錯誤,出現難以排查的問題時不好排查。- 建立衍生節點實際是建立一個個連結串列節點,其時間複雜度為O(n),節點多了會掉支效率變低。
優點
- 使用
context
可以更好的做併發控制,能更好的管理goroutine
濫用。 context
的攜帶者功能沒有任何限制,這樣我我們傳遞任何的資料,可以說這是一把雙刃劍- 網上都說
context
包解決了goroutine
的cancelation
問題,你覺得呢?
參考文章
https://pkg.go.dev/context@go...
https://studygolang.com/artic...
https://draveness.me/golang/d...
https://www.cnblogs.com/qcrao...
https://segmentfault.com/a/11...
https://www.flysnow.org/2017/...
總結
context
雖然在使用上醜陋了一點,但是他卻能解決很多問題,日常業務開發中離不開context
的使用,不過也別使用錯了context
,其取消也採用的channel
通知,所以程式碼中還有要有監聽程式碼來監聽取消訊號,這點也是經常被廣大初學者容易忽視的一個點。
文中示例已上傳github
:https://github.com/asong2020/...
好啦,本文到這裡就結束了,我是asong
,我們下期見。
**歡迎關注公眾號:【Golang夢工廠】