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間傳遞
參考: