Golang-Context掃盲與原理解析

Golang-Context掃盲與原理解析

一.什麼是Context?

  • context是一個包,是Go1.7引入的標註庫,中文譯做上下文,準確的說是goroutine的上下文,包含goroutine的運行狀態,環境,現場等信息。
  • context主要用於在goroutine之間傳遞上下文信息,比如取消信號,超時時間,截止時間,kv等。

二.爲什麼要有Context?

在Go中,控制併發有兩種經典的方式,一個是WaitGroup,另外一個就是context

  • WaitGroup:控制多個groutine同時完成,這是等待的方式,等那些必要的goroutine都工作完了我才能工作
  • Context:主動通知某一個groutine結束,這是主動通知的方式,通知某些groutine你不要再工作了

其實主動通知的方式,除了context,還有一種方式也可以實現

  • channle + select
func main() {
	stop := make(chan bool)

	go func() {
		for {
			select {
			case <-stop:
				fmt.Println("監控退出,停止了...")
				return
			default:
				fmt.Println("goroutine監控中...")
				time.Sleep(2 * time.Second)
			}
		}
	}()

	time.Sleep(10 * time.Second)
	fmt.Println("可以了,通知監控停止")
	stop<- true
	//爲了檢測監控過是否停止,如果沒有監控輸出,就表示停止了
	time.Sleep(5 * time.Second)
}

採用channle + select 這種方式來實現主動通知,有兩個致命的缺點:

  • 只能通知一個groutine結束,無法應對很多goroutine都需要結束的情況
  • 無法應對goroutine又衍生出其他更多的goroutine的情況

上述這兩種場景其實在業務中非常的常見

  • 場景1:比如一個網絡請求Request,每個Request都需要開啓一個goroutine做一些事情,這些goroutine又可能會開啓其他的goroutine,具體表現在Go的Server中,通常每一個請求都會啓動若干個goroutine同時工作,有些去數據庫拿數據,有些調用下游接口獲取相關數據,這些goroutine需要共享這個請求的基本數據,例如登錄token,處理請求的最大超時時間等等,當請求被取消或是處理時間太長,這時,所有正在爲這個請求工作的goroutine都需要快速退出,因爲他們的工作成果不再被需要了

爲應對上述場景,並且使得goroutine是可追蹤的,context應運而生

三.Context 如何使用?

1.context控制多個goroutine

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	go watch(ctx,"【監控1】")
	go watch(ctx,"【監控2】")
	go watch(ctx,"【監控3】")

	time.Sleep(10 * time.Second)
	fmt.Println("可以了,通知監控停止")
	cancel()
	//爲了檢測監控過是否停止,如果沒有監控輸出,就表示停止了
	time.Sleep(5 * time.Second)
}

func watch(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println(name,"監控退出,停止了...")
			return
		default:
			fmt.Println(name,"goroutine監控中...")
			time.Sleep(2 * time.Second)
		}
	}
}

上述樣例,context控制了三個goroutine,當context cancle之後,這三個goroutine便都退出了

2.傳遞共享數據

package main

import (
	"context"
	"fmt"
)

func main() {
	ctx := context.Background()
	process(ctx)

	ctx = context.WithValue(ctx, "traceId", "qcrao-2019")
	process(ctx)
}

func process(ctx context.Context) {
	traceId, ok := ctx.Value("traceId").(string)
	if ok {
		fmt.Printf("process over. trace_id=%s\n", traceId)
	} else {
		fmt.Printf("process over. no trace_id\n")
	}
}

3.取消goroutine,防止goroutine泄露

func gen(ctx context.Context) <-chan int {
    ch := make(chan int)
    go func() {
        var n int
        for {
            select {
            case <-ctx.Done():
                return
            case ch <- n:
                n++
                time.Sleep(time.Second)
            }
        }
    }()
    return ch
}

func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel() // 避免其他地方忘記 cancel,且重複調用不影響

    for n := range gen(ctx) {
        fmt.Println(n)
        if n == 5 {
            cancel()
            break
        }
    }
    // ……
}

如果只需要五個整數,在n==5時,直接break了沒有cancle,那麼就會存在goroutine泄露的問題!

四.Context 底層原理解析

1.Context的接口分析和實現

1,接口分析

type Context interface {
	Deadline() (deadline time.Time, ok bool)

	Done() <-chan struct{}

	Err() error

	Value(key interface{}) interface{}
}
  • Deadline() : 獲取設置的截止時間,第一個返回值是截止時間,到底這個時間點,context會自動發起取消請求,第二個返回值ok==false時表示沒有設置截止時間,如果需要取消的話需要調用函數cancle進行取消
  • Done() : 返回一個只讀的chan,類型爲struct{},我們在goroutine中,如果此方法返回的chan可讀,則意味着parent.context已發起取消請求,我們通過Done方法收到這個信號後,就應該做清理操作,然後退出goroutine,釋放資源
  • Err() : 返回取消的錯誤原因,即因爲什麼context被取消
  • Value(key) : 返回該context上綁定的值,是kv鍵值對,線程安全

它們都是冪等的。也就是說連續多次調用同一個方法,得到的結果都是相同的。

經典用法如下:

func Stream(ctx context.Context, out chan<- Value) error {
  	for {
  		v, err := DoSomething(ctx)
  		if err != nil {
  			return err
  		}
  		select {
  		case <-ctx.Done():
  			return ctx.Err()
  		case out <- v:
  		}
  	}
  }

2.接口實現

context根據其父子context關係,可以抽象成一顆樹,節點就是context

context接口並不需要我們實現,GO已經內置了兩個了,可以使用這兩個做完最頂層的父context,從而衍生出更多的子context

內置的根context:

  • background : 主要用於main函數,初始化以及測試代碼中,作爲context這個樹結構的最頂層根context
  • todo : 目前還不知道具體的使用場景,當你也不知道應該使用什麼context的時候,可以使用這個
var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

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

2.Context接口和類型間的關係

類圖如下:

圖來自此:https://blog.csdn.net/kevin_tech/article/details/119901843

通過上面的類圖,我們可以獲取以下信息:

  • 除了Context接口外還定義了一個叫做canceler的接口,帶取消功能的Context canclerCtx便是實現了這個接口
  • emptyCtx 什麼屬性也沒有,啥也不能幹
  • valueCtx 只能攜帶一個鍵值對,且自身要已付在上一級的Context上
  • timerCtx 繼承自canclerCtx 他們都是帶取消功能的Context
  • 除了emptyCtx,其他類型的Context都依附在上級Context上

3.Context的繼承衍生

有了根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系列的函數,都有一個parent參數,也就是父Context,我們要基於這個父Context創建出子Context,可以理解爲子Context對父Context的繼承,也可以理解爲基於父Context的衍生。這四個with系列的函數,只是創建子Context的條件不同而已

通過這四個函數,我們就可以創建出一顆Context樹,樹的每個節點都可以有多個任意的子節點,節點的層級可以有任意多個

  • WithCancle : 傳入一個父Context,返回一個子Context以及一個取消函數(用來取消Context)

  • WithDeadline : 傳入一個父Context和一個截止時間,同樣返回一個子Context和一個取消函數,意味着到了這個截止時間,會自動取消Context,當然我們也可以通過取消函數提前進行取消

  • WithTimeout : 和WithDeadline差不多,表示超時自動取消,是多少時間後自動取消Context的意思

  • WithValue : 和取消Context無關,它是爲了生成綁定了一個鍵值對數據的Context,這個數據可通過Context.value訪問

可以注意到上述幾個函數都會返回一個取消函數,CancelFunc

  • CancelFunc: 取消一個Context,以及這個Context節點下的所有子Context,不管有多少層,不管有多少數量

4.Context的數據傳遞與使用

  • 我們通過context.WithValue函數生成一個context,通過.Value函數獲取Context鍵值對的值
var key string="name"

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	//附加值
	valueCtx:=context.WithValue(ctx,key,"【監控1】")
	go watch(valueCtx)
	time.Sleep(10 * time.Second)
	fmt.Println("可以了,通知監控停止")
	cancel()
	//爲了檢測監控過是否停止,如果沒有監控輸出,就表示停止了
	time.Sleep(5 * time.Second)
}

func watch(ctx context.Context) {
	for {
		select {
		case <-ctx.Done():
			//取出值
			fmt.Println(ctx.Value(key),"監控退出,停止了...")
			return
		default:
			//取出值
			fmt.Println(ctx.Value(key),"goroutine監控中...")
			time.Sleep(2 * time.Second)
		}
	}
}

在上面的樣例中,我們生成了一個新的Context,這個新的Context帶有這個鍵值對,在使用的時候,可以通過Value的方法讀取,ctx.Value(key)

五.Context FQA

1.Context使用事項

在官方博客裏,對於使用 context 提出了幾點建議:

  • 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.
  • Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use.
  • Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
  • The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines.

翻譯一下:

  • 不要將Context塞到結構體裏,直接將Context類型作爲函數的第一參數,而且一般都命名爲ctx
  • 不要向函數傳入一個nil的Context,如果你實在不知道傳什麼,標準庫給你準備好了一個context:todo
  • 不要把本應該作爲函數參數的數據塞入到context中,context存儲的應該是一些共同數據,比如登錄的session,cookie等
  • 同一個context可能會被傳遞到多個goroutine,別擔心,context是併發安全的

2.到底有幾類的Context?

  • 類型一,emptyCtx,Context的源頭
  • 類型二,cancelCtx,cancle機制的靈魂
  • 類型三,timerCtx,cancle機制場景的補充
  • 類型四,valueCtx,傳值需要

這幾類的context組成了一顆context樹!

3.context存儲值的底層是一個Map嗎?

  • 不是
  • 每一個KV映射都對應一個valueCtx,是一個個節點,當傳遞多個值時就要構建多個valueCtx,同時這也是context不能從底向上傳遞值的原因
  • 在調用value獲取鍵值對的值的時候,會首先在本context尋找對應key,如果沒有找到則會在父context中遞歸尋找

4. Context 是如何實現數據共享的?

圖來自:https://blog.csdn.net/kevin_tech/article/details/119901843

  • 數據共享即:元數據在任務間的傳遞
  • 其實現的Value方法能夠在整個Context樹鏈路上查找指定鍵的值,直到回溯到根Context,也就是emptyCtx,這也是emptyCtx什麼功能也不提供的原因,因爲他是作爲根節點而存在的。
  • 每次要在Context鏈路上增加攜帶的KV時,都要在上級Context的基礎上新建一個ValueCtx存儲KV,而且只能增加不能修改,讀取KV也是一個冪等操作,所以Context就這樣實現了併發安全的數據共享機制,並且全程無鎖,不會影響性能

5. Context 是如何實現以下三點的?

  • 上層任務取消後,所有的下層任務都會被取消
  • 中間某一層的任務取消後,只會將當前任務的下層任務取消,而不會影響上層的任務已經同級任務

分析如下:

  • 首先在 創建帶取消功能的Context時還是要在父Context節點的基礎上創建,從而保持整個Context鏈路的連續性,除此之外,還會在Context鏈路中找到上一個帶取消功能的Context,把自己加入到他的children列表裏,這樣在整個Context鏈路中,除了父子Context之間有之間關聯外,可取消的Context還會通過維護自身攜帶的Children屬性建立與自己下級可取消的Context的關聯,具體可參考下圖

圖來自:https://blog.csdn.net/kevin_tech/article/details/119901843

  • 通過上圖的這種設計,如果要在整個任務鏈路上取消某個canclerCtx時,就既能做到取消自己,也能通知下級CancelCtx進行取消,同時還不會影響到上級和同級的其他節點。

五.總結

context主要用於父子goroutine之間同步取消信號,本質上是一種協程的調度方式,另外有兩點需要注意:

  • context的取消操作是無侵入的,上游任務僅僅使用context通知下游任務不再被需要,但不會直接干涉下游任務的執行,由下游任務自己決定後續的操作。

  • context是併發安全的,因爲context本身是不可變的,可以放心在多個goroutine間傳遞

參考:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章